Supabase JWT signing keys broke auth.uid()
If auth.uid() started returning NULL — and inserts fail with 42501 while updates return success but change nothing — after you migrated to asymmetric JWT signing keys, PostgREST is resolving your valid user tokens as anon. Re-import and re-align the signing key in the dashboard so the Data API can fetch the right public key from /.well-known/jwks.json, and switch your own verification code to supabase.auth.getClaims().
This one is nastier than a normal auth break, because half of it doesn't look like a break at all. Supabase's Auth server (GoTrue) happily verifies the user's JWT — getUser() returns the right person, your app renders the right name in the header. But the Data API resolves the same token as the anonymous role. So:
auth.uid()evaluates to NULL inside every RLS policy.- INSERTs fail loudly with
42501— anew row violates row-level security policyshaped error. - UPDATEs and DELETEs succeed with a 200 and affect zero rows, because RLS filters the target rows away before the write ever matches anything.
That last one is the dangerous half. A silent no-op on a write path is a data-loss bug that no error monitor will page you about.
What actually changed
Supabase moved from a single shared symmetric secret (the legacy JWT secret, HS256) to asymmetric signing keys — ECC P-256 (ES256) or RSA — where Auth signs tokens with a private key and everything else verifies them with a public key published at:
https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json
Alongside that, PostgREST v13 rolled out on 24 July 2025 with, in Supabase's own words, a more strict approach to JWT validation. The combination is what bites: a project that has been migrated to asymmetric keys but whose Data API can't successfully resolve the current key from JWKS no longer quietly falls back to trusting the token. It treats the request as unauthenticated.
Reported in supabase discussion #38771: after the migration, "the data API resolves them as
anon(auth.uid()NULL →42501on INSERT; authenticated UPDATEs silently no-op)" — with both ES256 and RS256 keys, despite the key being configured.
Note the shape of the failure. Your RLS policies are fine. Your client is fine. The token is fine. The only broken thing is the Data API's ability to prove it.
How do I fix auth.uid() returning NULL after the JWT key migration?
Confirm it's the key, not your policy, then re-align the signing key in the dashboard.
1. Prove the token is being resolved as anon. Create this helper in the SQL editor, then call it from your app with supabase.rpc('whoami') while signed in — running it in the editor proves nothing, because the editor carries no user JWT and will always report NULL:
create or replace function public.whoami()
returns jsonb
language sql
stable
set search_path = '' -- pin it, same as any function you ship
as $$
select jsonb_build_object(
'uid', auth.uid(),
'role', auth.role(),
'sub', auth.jwt() ->> 'sub'
);
$$;
If role comes back as anon while your app clearly has a signed-in user, you have this bug and not a policy bug. (If uid is NULL because you tested in the SQL editor, that's a different and much more common false alarm.)
2. Check the JWKS endpoint is actually serving your key.
curl -s https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json | jq
An empty keys array means the project isn't on asymmetric keys at all. A key whose kid doesn't match the kid in your access token header means the Data API is looking for a key it can't find — decode the token at the header level and compare.
3. Re-import / re-align the signing key. In the dashboard under Settings → JWT (/project/_/settings/jwt), use the migrate-and-rotate flow — the Migrate JWT secret button, then Rotate keys: import the legacy secret, create the standby asymmetric key, then rotate. Supabase's fix for the mismatched-key case is to re-import the key so both services agree on the current one. Give the JWKS discovery cache time to catch up — the endpoint is cached about 10 minutes at the edge and again by client libraries, so don't judge the fix in the first 60 seconds.
4. Don't revoke the legacy secret too early. If access tokens live an hour, tokens signed with the old key are still in the wild for an hour. Supabase's guidance is to wait at least an hour and fifteen minutes after rotation before revoking. Revoke early and you invalidate every session in flight.
The fix you must not ship
When writes start silently failing, the fastest-looking way out is to widen the policy until things work again:
-- "temporary, just to unblock the release"
create policy "allow all" on public.documents
for all using (true) with check (true);
This does make the symptom disappear, because it stops the policy from depending on auth.uid() at all. It also makes every row in documents readable and writable by anyone holding the public anon key — which is to say, anyone who opens DevTools. GuardLayer flags exactly this pattern in migrations, and it is the single most common way an app with RLS "enabled" is still wide open.
The same applies to the other panic fix: routing the write through a server route that uses the service_role key. That works, and it also removes RLS from the equation permanently — you've replaced a broken key configuration with a deliberate policy bypass, and nobody will remember to undo it.
Migrate your own verification code too
The migration is also the moment to fix any code that verifies Supabase JWTs itself. If you have middleware or an Edge Function calling jwt.verify(token, process.env.SUPABASE_JWT_SECRET), it will break the instant the legacy secret is revoked — and Supabase warns about exactly this before you rotate.
The replacement is supabase.auth.getClaims(), which verifies the token locally with the WebCrypto API against the cached JWKS for asymmetric projects, and falls back to a call to the Auth server for symmetric ones. It's the fast path and the correct one.
There's a related footgun worth catching in the same pass. Middleware that gates access on getSession() never verified anything to begin with:
const { data } = await supabase.auth.getSession();
if (!data.session) return NextResponse.redirect(new URL("/login", request.url));
- Warningmiddleware.ts:12
getSession() trusted in server code
In server code (middleware, route handlers, server actions) authorize with supabase.auth.getUser() — it revalidates the JWT — not getSession(). getSession() is fine on the client, where the session is already trusted.
On the server, getSession() reads the session straight out of the request cookies without checking the signature, so a forged cookie walks past the redirect. Use getUser() (revalidates with the Auth server) or getClaims() (verifies against JWKS) for anything that decides authorization — the full comparison is here. Code like this survives a key migration precisely because it isn't validating keys, which is the problem.
Quick self-check
# 1. Anything still verifying against the legacy secret?
grep -rn "SUPABASE_JWT_SECRET\|jwt.verify" --include=*.ts --include=*.tsx .
# 2. Server-side getSession() used as an auth gate?
grep -rn "auth.getSession()" middleware.ts app/ 2>/dev/null
# 3. Is the project actually serving asymmetric keys?
curl -s https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json
FAQ
Why does auth.uid() return NULL only after migrating signing keys?
Because the Data API can no longer resolve your token to a user. PostgREST v13 validates JWTs strictly against the key published at the JWKS endpoint; if it can't match the token's kid, it treats the request as anon, and auth.uid() in every policy evaluates to NULL.
Why do my UPDATEs return success but change nothing?
RLS filters rows before the update applies. With auth.uid() NULL, a USING (auth.uid() = user_id) policy matches zero rows, so the statement legitimately updates nothing and PostgREST returns 200. Add .select() to your update and assert on the returned row count if you want this to fail loudly.
Should I roll back to the legacy JWT secret? No. Asymmetric keys are the direction of travel and they let you verify tokens locally without a round-trip to Auth. Re-import the key and let the JWKS cache settle instead.
Does this affect Edge Functions too?
Yes, if the function verifies the JWT itself against the legacy secret. Switch it to getClaims() or verify against the JWKS endpoint.
How long until rotation takes effect everywhere? Budget for the JWKS cache (roughly 10 minutes at the edge plus 10 in client libraries) and the lifetime of already-issued access tokens (an hour by default) before revoking anything.
Catch this before it ships — free
GuardLayer scans every push for this and 28 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.