Build1 publisher2 min readPublished
Moving a browser-queried Supabase app shut all 228 RLS policies until auth.uid() moved too
One developer moving a 99-table SaaS off Supabase Cloud saw all 228 row-level security policies fail silently until auth.uid() moved with the database. Any app that queries Postgres from the browser has to bring its auth and session layers across in the same cutover.
The Engineer · Build desk

What happened
- The Vite front end has no backend of its own; its client bundle makes 272 .from() calls, 28 rpc calls and 26 storage calls, each carrying a JWT and gated by RLS.
- PostgREST already runs SET LOCAL request.jwt.claims from the bearer token on every request, so auth.uid() only has to read that session variable back.
- Replaying the migrations on clean Postgres failed five runs in a row, each failure exposing an object Supabase Cloud had created on its own.
- At startup, GoTrue's own migrations replaced the custom auth.uid() with a version that reads only request.jwt.claim.sub, a variable PostgREST 16 does not set.
- The author let GoTrue own and migrate the auth schema first, then restored the function bodies with CREATE OR REPLACE.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- cost Every GoTrue image upgrade on a self-hosted server now carries a restore step for auth.uid(), and whoever runs the box owns it for as long as the two claim shapes differ.
- exposure A missed restore shows logged-in users empty screens while the logs stay clean, so alerting built on error rates will not see the outage.
- decision Teams leaving Supabase Cloud need an idempotent bootstrap SQL file, run before any migration replay, that recreates the roles, auth schema, realtime publication, extensions schema and grants.
Leaving Supabase is possible because the dashboard's single product is a Postgres database plus four open-source services that run anywhere, according to the developer's account on dev.to [18]. In this app, the client bundle's 326 calls to tables, rpc functions and storage all reach Postgres with a JWT attached [1][3]. Every policy depends on one small function. `auth.uid()` reads a session variable that the connection holder sets from the token before the query runs [5].
"I expected this to be the expensive part. It was twenty lines," the developer wrote [6]. The replacement checks the flat variable `request.jwt.claim.sub` first, then the `sub` key inside the JSON in `request.jwt.claims` [5]. "I did not rewrite a single one of the 228 policies," the author wrote, calling that "the difference between a week of work and a quarter of it" [8][9].
The move took about a week [2]. For that figure to hold on another codebase, its policies have to reference only objects a bootstrap file can recreate. This one needed the `anon`, `authenticated` and `service_role` roles, plus an `auth` schema holding `auth.uid()`, `auth.role()`, `auth.jwt()`, `auth.email()` and an `auth.users` table [10]. Migrations also assumed the `supabase_realtime` publication exists [10]. Extensions need their own `extensions` schema. Installed into `public`, `pgcrypto` is dropped on rebuild and `gen_random_uuid()` goes with it [11].
Grants were the fifth gap [10]. Without a `GRANT` to `authenticated`, Postgres refuses the request at the table with `permission denied` before RLS is evaluated [12]. "It reads exactly like broken RLS. It isn't. I lost an hour here," the developer wrote [12].
The replacement reads two places because of GoTrue [14]. Each service is consistent with itself. Together they disagree about where `sub` lives [14]. Under GoTrue's version, the author got NULL on every request and nothing in the logs [15]. "It looks like the data vanished," the developer wrote [15].
The ordering fix is good engineering [16]. `CREATE OR REPLACE` keeps the object identity the policies are bound to, so they never see the swap [16]. I'd wire the restore into the same deploy that bumps the GoTrue image. A forgotten manual step fails as silently as the original bug [15].
Loading the data had one more trap [17]. Disabling triggers is the standard advice for a dump with circular references. But `pg_dump` adds foreign keys at the end as `ALTER TABLE ADD CONSTRAINT`, and that statement runs a full validation pass anyway [17]. The author's load failed on `users.auth_uid` pointing at `auth.users` [17].
What to watch
- A GoTrue release whose auth.uid() also reads the request.jwt.claims JSON that PostgREST 16 sets would remove the restore after each image bump.
- How the author got the dump loaded past the full foreign-key validation pg_dump runs at the end, a pass that disabled triggers do not skip.