Bug report
Describe the bug
On a self-hosted Supabase deployment, the auth.uid() / auth.role() /
auth.email() helper functions installed by the init/bootstrap SQL only read
the legacy per-claim GUCs (request.jwt.claim.sub, etc.). Modern PostgREST
(v9+, here 14.8) no longer sets those — it only sets the aggregated JSON GUC
request.jwt.claims. As a result auth.uid() returns NULL for every
authenticated request, so every RLS policy of the form
user_id = auth.uid() matches zero rows. All tables (profiles, report_labels,
reports, histories) become effectively unreadable/unwritable for authenticated
users, even though the JWT is valid and verified.
The init should ship the official coalesce-based definition that supports both
the old per-claim GUC and the new request.jwt.claims JSON, so it stays
compatible with the bundled PostgREST version.
To Reproduce
- Deploy self-hosted Supabase (Helm chart, PostgREST 14.8) and create the
standard RLS policies, e.g.:
create policy "profiles_select_own" on profiles
for select using (user_id = auth.uid());
- Sign in a normal user (GoTrue password grant) and call REST with the user's
access token (NOT the postgres/service_role key — those bypass RLS):
curl -s -H "apikey: $PUB" -H "Authorization: Bearer $USER_TOKEN" \
"$URL/rest/v1/profiles?select=id"
→ returns [] although a matching row exists (visible in Studio, which runs
as postgres and bypasses RLS).
- Diagnose what the DB sees for that request:
create or replace function public.debug_jwt() returns jsonb
language sql stable as $$
select jsonb_build_object(
'uid', auth.uid(),
'role', auth.role(),
'claims', current_setting('request.jwt.claims', true)
);
$$;
grant execute on function public.debug_jwt() to anon, authenticated;
curl -s -X POST "$URL/rest/v1/rpc/debug_jwt" \
-H "apikey: $PUB" -H "Authorization: Bearer $USER_TOKEN"
Actual output:
{
"uid": null,
"role": null,
"claims": "{\"sub\":\"ae971efe-...\",\"role\":\"authenticated\",\"aud\":\"authenticated\", ...}"
}
→ request.jwt.claims is populated (contains sub and role), but
auth.uid() and auth.role() are NULL, proving the helper functions read
the wrong (now-empty) GUC.
Expected behavior
auth.uid() should return the sub claim of the verified JWT regardless of how
the bundled PostgREST version exposes claims, so RLS policies like
user_id = auth.uid() work out of the box. The init/bootstrap SQL should install
the GUC-compatible definition:
create or replace function auth.uid() returns uuid language sql stable as $$
select coalesce(
nullif(current_setting('request.jwt.claim.sub', true), ''),
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')
)::uuid
$$;
create or replace function auth.role() returns text language sql stable as $$
select coalesce(
nullif(current_setting('request.jwt.claim.role', true), ''),
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role')
)
$$;
create or replace function auth.email() returns text language sql stable as $$
select coalesce(
nullif(current_setting('request.jwt.claim.email', true), ''),
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'email')
)
$$;
Applying the above immediately fixes debug_jwt (uid becomes the user's UUID)
and all RLS policies start returning the correct rows — no policy or schema-cache
changes required.
Screenshots
N/A (CLI output included above).
System information
- OS: Linux (Kubernetes, self-hosted)
- Deployment: self-hosted Supabase via Helm chart on StackGres
- PostgreSQL: 17.4 (StackGres)
- PostgREST: 14.8
- GoTrue (Auth): v2.186.0
- JWT: HS256 (symmetric, shared
JWT_SECRET)
- Client: supabase-flutter 2.14.2 (Brick offline-first), but reproducible with
plain curl as shown
Additional context
- The standard/managed Supabase stack and the Supabase CLI ship the
coalesce-based auth.uid(), so the issue does not appear there — it only
surfaces on hand-assembled self-hosted stacks where an older auth.uid()
definition is paired with a newer PostgREST.
- Studio (role
postgres) bypasses RLS, so the rows look present there, which
makes the bug easy to misattribute to the app/policies rather than to
auth.uid().
- Root cause is the PostgREST change from per-claim GUCs (
request.jwt.claim.*)
to the aggregated request.jwt.claims JSON; the init SQL must use a definition
that reads the JSON (with a fallback) to stay version-compatible.
Bug report
Describe the bug
On a self-hosted Supabase deployment, the
auth.uid()/auth.role()/auth.email()helper functions installed by the init/bootstrap SQL only readthe legacy per-claim GUCs (
request.jwt.claim.sub, etc.). Modern PostgREST(v9+, here 14.8) no longer sets those — it only sets the aggregated JSON GUC
request.jwt.claims. As a resultauth.uid()returnsNULLfor everyauthenticated request, so every RLS policy of the form
user_id = auth.uid()matches zero rows. All tables (profiles, report_labels,reports, histories) become effectively unreadable/unwritable for authenticated
users, even though the JWT is valid and verified.
The init should ship the official
coalesce-based definition that supports boththe old per-claim GUC and the new
request.jwt.claimsJSON, so it stayscompatible with the bundled PostgREST version.
To Reproduce
standard RLS policies, e.g.:
access token (NOT the
postgres/service_rolekey — those bypass RLS):[]although a matching row exists (visible in Studio, which runsas
postgresand bypasses RLS).{ "uid": null, "role": null, "claims": "{\"sub\":\"ae971efe-...\",\"role\":\"authenticated\",\"aud\":\"authenticated\", ...}" }request.jwt.claimsis populated (containssubandrole), butauth.uid()andauth.role()areNULL, proving the helper functions readthe wrong (now-empty) GUC.
Expected behavior
auth.uid()should return thesubclaim of the verified JWT regardless of howthe bundled PostgREST version exposes claims, so RLS policies like
user_id = auth.uid()work out of the box. The init/bootstrap SQL should installthe GUC-compatible definition:
Applying the above immediately fixes
debug_jwt(uidbecomes the user's UUID)and all RLS policies start returning the correct rows — no policy or schema-cache
changes required.
Screenshots
N/A (CLI output included above).
System information
JWT_SECRET)plain curl as shown
Additional context
coalesce-basedauth.uid(), so the issue does not appear there — it onlysurfaces on hand-assembled self-hosted stacks where an older
auth.uid()definition is paired with a newer PostgREST.
postgres) bypasses RLS, so the rows look present there, whichmakes the bug easy to misattribute to the app/policies rather than to
auth.uid().request.jwt.claim.*)to the aggregated
request.jwt.claimsJSON; the init SQL must use a definitionthat reads the JSON (with a fallback) to stay version-compatible.