Fix Supabase 'invalid claim: missing sub claim' (401)
TL;DR: The string invalid claim: missing sub claim is emitted by GoTrue (Supabase Auth), not PostgREST — its error_code is bad_jwt. It means a JWT did reach the Auth server but carries no sub (user id) claim, so Auth refuses to identify a user from it. Almost always you passed an anon/publishable or service/secret API key where a user access token belonged, or a custom token hook / third-party provider dropped sub at issuance. Decode the token payload: if you see role: "anon" or "service_role" and no sub, you're sending an API key, not a session token. Match on the msg text — the HTTP status has been observed as both 401 and 403 for this same message.
Who actually emits "invalid claim: missing sub claim"?
First, correct the framing, because it sends people to the wrong layer. The common phrasing is "PostgREST said missing sub claim." It didn't. This exact string comes from GoTrue, the Supabase Auth server, and it surfaces when Auth is handed a token to validate that has no sub. The typical call sites are supabase.auth.getUser(jwt) and setSession() — anywhere you ask Auth to resolve a user from a token. A full response body looks like:
{ "code": 403, "error_code": "bad_jwt", "msg": "invalid claim: missing sub claim" }
You may also see it thrown client-side as AuthApiError: invalid claim: missing sub claim with status: 401. The status wobbles across GoTrue versions; the stable signal is the msg text (or the decoded, sub-less claims). Don't build your handling around the status code — mirror the same "match the stable signal" rule from the Supabase JWT expired writeup, which keys on PGRST301 rather than the wording.
Decode the token before you change any code
Every fix below depends on knowing what's actually inside the token you sent. Decode the middle segment:
JSON.parse(atob(token.split('.')[1]))
Or paste the token into jwt.io. You're checking two claims:
sub— present? It should be the user's id. If it's missing, that's your error, full stop.role— is it"authenticated", or"anon"/"service_role"?
If the payload has role: "anon" (or service_role) and no sub, you are looking at a Supabase API key, not a user session token. The legacy anon and service_role keys are themselves JWTs — they carry a role claim but intentionally no sub, because they don't represent a user. The newer sb_publishable_… / sb_secret_… keys aren't user tokens either. This is the same decode step used in the Clerk + Supabase RLS setup, and it resolves this bug faster than any amount of reading server logs.
What causes it, and the fix per cause
1. You passed an API key where an access token was expected (the #1 cause). The reason is structural: these API keys are recognized by Supabase Auth but deliberately carry no sub claim, because they don't represent a user. You hardcoded the anon key into a Bearer header, or your server client is initialized with the service key and you called getUser() with it. Fix: send the user's access token — Authorization: Bearer <user_jwt> — or initialize the @supabase/ssr server client so it reads the session from the request cookie. This expands the same explanation the JWT-expired post gives; refreshing does nothing here because there is no user session to refresh.
2. A custom access token hook dropped sub (or role) at issuance. If you use the custom access token hook, Auth validates the returned claims after the hook runs and rejects the token if required ones are absent. The documented required set includes iss, aud, exp, iat, sub, role, aal, session_id, email, phone, is_anonymous. A hook that builds a fresh claims object instead of extending the existing one strips sub and fails token issuance. Fix: spread the original claims and only add to them — never replace:
const claims = event.claims // keep sub, role, everything
claims.app_metadata = { ...claims.app_metadata, plan: 'pro' }
return { claims }
The same mechanism applies to role: a hook that drops role fails issuance too. (Note: that's the mechanism — the verified verbatim string is missing sub claim; don't assume the role variant is worded identically.)
3. A third-party provider token isn't mapped to include sub. Supabase's native third-party auth supports exactly five providers — Clerk, Auth0, Firebase, AWS Cognito, and WorkOS. Register the issuer under Authentication → Third-Party Auth, configure supabase-js with an accessToken callback returning the provider token, and let RLS read verified claims via auth.jwt(). If the provider token has no sub in the position Auth reads, you get this error. Watch the sub type, too: Cognito sub is UUID-shaped, so auth.uid() works — see Cognito + Supabase RLS. WorkOS user ids look like user_01H…, a prefixed string, not a UUID; RLS must compare auth.jwt()->>'sub' against a text column and must not use auth.uid(), which casts sub to uuid and throws 22P02 / returns null on a non-UUID. That is the same family of failure covered in WorkOS + Supabase RLS.
4. A manually-signed or self-hosted token — different error. If your token's signature doesn't match the project key, you don't get missing sub; you get a PostgREST error: 401 { code: 'PGRST301', message: 'JWSError JWSInvalidSignature' }. Common on self-hosted with a JWT_SECRET mismatch (including hex-encoding mistakes), or a mis-registered third-party issuer. Don't match on PGRST301 alone — that code is shared with JWT expired.
missing sub vs. auth.uid() null vs. expired vs. bad signature
Four failures get lumped together as "the 401." They aren't the same, and only some produce a claim-error string at all:
| Symptom | What it means | Fix |
|---|---|---|
invalid claim: missing sub claim / bad_jwt (401 or 403) | A JWT reached Auth but has no sub — wrong token type | Send the user's access token, not an API key |
auth.uid() is NULL under RLS, no error | No JWT reached Postgres (or wasn't forwarded); policy denies silently | Forward the token; see auth.uid() null under RLS |
PGRST301 + JWT expired (401) | Token had sub, was valid, aged out | Refresh — see Supabase JWT expired |
PGRST301 + JWSError JWSInvalidSignature (401) | Signature doesn't match the project key | Fix the signing key / issuer registration |
One more that trips people: a valid-signature token with no role does not throw an "invalid claim" string. PostgREST falls back to the anonymous role, so the symptom is anon-level access — empty results or permission denied for table (42501), covered in permission denied for table. Supabase tokens need "role": "authenticated" for TO authenticated policies to match.
FAQ
Is invalid claim: missing sub claim a PostgREST error?
No. It's a GoTrue / Supabase Auth error with error_code: bad_jwt, usually raised by getUser() or setSession(). Misreading it as a PostgREST error sends you to the wrong layer and the wrong fix.
Why is the status 401 for me but 403 in someone else's report?
The HTTP status for this message has been observed as both across GoTrue versions. The message text and the bad_jwt code are stable; the status isn't. Key your handling on the msg.
I'm sure I'm logged in — why no sub?
Decode the token you're actually sending. If it shows role: "anon" or "service_role" and no sub, your client is sending an API key, not the signed-in user's access token. That's the #1 cause.
Does refreshing the session fix it?
No. Refresh solves JWT expired, where a real user token aged out. missing sub means there was never a user token to begin with — refreshing an API key gives you the same API key.
My WorkOS users hit this or a UUID cast error — why?
WorkOS sub is a string like user_01H…, not a UUID. Compare auth.jwt()->>'sub' to a text column and avoid auth.uid(). Cognito's sub is UUID-shaped, so it behaves differently.
Where GuardLayer fits (and where it doesn't)
Be precise: missing sub claim is a runtime token problem. GuardLayer is a static scanner — it reads your source and SQL migrations, not live requests. It cannot see the JWT on the wire, cannot read decoded claims, and cannot tell which token your client sent, so it will never detect this 401. The diagnosis is the decode step above, not a scan.
What it can flag are the adjacent code-level mistakes people reach for when stuck: a service_role key wired into client-side code (the exact exposure in service role key exposed), RLS never enabled on a table, or non-user-scoped policies. Those are real findings — just don't expect a static tool to catch the token error itself. For the broader model of how a JWT reaches your queries and drives policy evaluation, see the complete guide to Supabase Row Level Security.
Catch this before it ships — free
GuardLayer scans every push for this and 24 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.