Skip to content

Security basics

Supabase "invalid claim: missing sub claim": nobody is signed in

"invalid claim: missing sub claim" means Supabase Auth got a genuine token that names no user: your project key, sent where a session belongs.

Vlad Tkachenko11 min read
An ID badge on a lanyard with a genuine seal and a blank name line, in front of two badges that carry a name and a face.

In short

  • "invalid claim: missing sub claim" is Supabase Auth answering 403 with the code bad_jwt. It was asked who is calling, the token it got was genuine, and the token named nobody.
  • Supabase's own troubleshooting page names the common cause: an API key sent where a signed-in user's access token belongs. A key says which app is calling and has no person in it.
  • Call the function only once somebody is signed in, and have it answer 401 when nobody is. Swapping in the secret key changes nothing, because that key names nobody either.

Your app has a Supabase Edge Function that does something for the person using it, such as saving their settings or calling OpenAI on their behalf. It worked while you were testing it. Then the function's log starts filling with this, and the app shows an error or nothing at all:

AuthApiError: invalid claim: missing sub claim

Search "invalid claim: missing sub claim" and you will be told to regenerate your keys, to switch off Verify JWT, or to put the service_role key in. Each of those treats the token as broken. The token was fine. Supabase checked its signature and accepted it. What it lacked was anybody's name.

What does "invalid claim: missing sub claim" mean?

Supabase Auth was asked which user is calling, found the token genuine, and found no user written in it.

The tokens Supabase Auth reads are JWTs: three chunks of text separated by dots, where the middle chunk is readable data. Each item in that data is called a claim. sub, short for subject, is the claim that holds a user's id, and Supabase's JWT reference describes it as the user ID, a UUID.

The message is written in Supabase Auth's own source, in a function called maybeLoadUserOrSession in internal/api/auth.go. By the time that function runs, the token's signature has already been checked and has passed. It then looks for sub, and when the claim is empty it stops with HTTP 403, the error code bad_jwt, and this sentence. The reply your code receives looks like this:

{"code":403,"error_code":"bad_jwt","msg":"invalid claim: missing sub claim"}

Picture a front desk that has been asked one question: which visitor is this? You hand over a badge. The desk checks the stamp on it first, and the stamp is real. Then it reads the name line, and the name line is blank. The desk has nothing to answer with, so it refuses, and the refusal names the blank line.

Any call that needs a signed-in person can get this answer. getUser() and updateUser() are the two you are most likely to see it from, and adding a second factor or linking another sign-in method goes through the same check.

The seal is checked first, and both badges pass it. The question that fails is the second one: whose name is on it.

A key names your app. A session token names a person.

Your project's API key is the badge with the blank name line, and it was made that way on purpose.

Supabase's API keys documentation says it in one line: "An API key doesn't distinguish between users but between applications." The key tells Supabase which app is calling. Who is using that app is a separate question, answered by a token Supabase Auth issues to each person when they sign in.

Supabase's JWT reference prints both. An older project's anon key decodes to this, and there is no sub anywhere in it:

{
  "iss": "supabase",
  "ref": "abcdefghijklmnopqrst",
  "role": "anon",
  "iat": 1640991600,
  "exp": 1640995200
}

A signed-in person's access token carries their id, the role authenticated and the session it belongs to (trimmed here to the lines that matter):

{
  "sub": "123e4567-e89b-12d3-a456-426614174000",
  "role": "authenticated",
  "session_id": "123e4567-e89b-12d3-a456-426614174000",
  "email": "user@example.com"
}

The service_role key is the first shape again with "role": "service_role", and still no sub. The newer sb_publishable_ and sb_secret_ keys are not JWTs at all, but Supabase's troubleshooting page for this error says Auth recognises them and that they "do not carry the sub claim" either. So every key your project has, old or new, public or secret, produces this message when it is handed to Auth as if it were a person. That same page names the mistake directly: "inadvertently using a Supabase API key" instead of the Auth access token.

If keys and their prefixes are new to you, which API keys are safe in your frontend covers how to tell the public one from the secret one.

Where the key gets sent in place of a session

Three routes lead here, and the first one is the one an Edge Function takes.

1. An Edge Function called from a page where nobody is signed in. When supabase-js has a session, it puts the person's token in the Authorization header. When it has none, it has nothing else to put there, so with a legacy key it sends your project's key in that header instead. Its fetch.ts reads exactly that way: the session token if there is one, otherwise the key.

The platform's Verify JWT check lets that request in. A legacy key is a JWT your project signed, and Supabase documents that the check accepts the newer keys as well, which is the gap moving a secret to an Edge Function explains in full. Your function then passes the header on to Auth, which is what these lines do:

const supabase = createClient(
  Deno.env.get('SUPABASE_URL')!,
  Deno.env.get('SUPABASE_ANON_KEY')!,
  { global: { headers: { Authorization: req.headers.get('Authorization')! } } },
)
const { data: { user } } = await supabase.auth.getUser()

Auth reads the key, finds the blank name line, and answers 403. If the function hands that failure back, your app shows Edge Function returned a non-2xx status code, with this message waiting in the function's log.

2. A call made before sign-in had finished. Supabase's troubleshooting page names this one as well: a user who started to sign up or sign in and had not completed it when getUser was called. One shape this takes is a sign-up still waiting for its confirmation email. There is no session yet, so whatever reached Auth was not a person's token.

3. Your own server hands Auth the wrong value. A route that calls getUser(token) with the key in token, or an automation in n8n, Make or Postman that calls /auth/v1/user with the key in its Authorization header. The fix Supabase's page gives for this case is to make sure the token passed to getUser() is the person's access token.

With nobody signed in, the page sends the same key in both headers. Every check before Auth lets it through, and only Auth's question finds the name line empty.

How to see which one your function received

Compare two request headers in your browser.

Open your live app, press F12, choose the Network tab and repeat whatever failed. Click the request whose address contains /functions/v1/ and your function's name, and read its request headers:

  • Authorization holds Bearer and then the same string as apikey. The page had no session to send, so it sent the key twice. You are on route 1 or 2.
  • Authorization holds a different long string beginning eyJ. A person's token went out. The function or your server lost it on the way, so you are on route 3.
  • There is no Authorization header. Then this message is not the one you will meet. With Verify JWT on, the platform refuses the request with a 401 before your code runs, which the non-2xx post reads. With it off, Auth answers This endpoint requires a valid Bearer token, the first row of the table further down.

If the message is in your browser's console instead, and reads Auth session missing!, no request reached Auth at all: supabase-js found no session and stopped before sending one, which its own post covers.

Read the values where they are. Do not paste either one into a website that offers to decode it: your key is public anyway, but a person's access token lets whoever holds it act as that person until it expires.

The fix: ask only when someone is signed in

Decide what the function should do for a visitor who has not signed in, and write that decision into the function.

If only signed-in people should use it, two changes. The page calls the function only once somebody has signed in. And the function answers a plain 401 when getUser() finds no user, so a stray call from a signed-out page gets a clear refusal instead of a crash:

const { data: { user } } = await supabase.auth.getUser()
if (!user) {
  return new Response(JSON.stringify({ error: 'Sign in first' }), {
    status: 401,
    headers: { ...corsHeaders, 'Content-Type': 'application/json' },
  })
}

Keep the CORS headers on that reply as well, or the browser hides it from your app and you meet "Failed to send a request to the Edge Function" instead. Supabase also ships a wrapper, withSupabase({ auth: 'user' }), that makes the same check before your handler runs; the Edge Function post shows the setup.

If signed-out visitors should use it too, ask Auth only on the path that needs a person. Treat a refusal there as "this is a visitor" and carry on with what a visitor is allowed, rather than letting the error stop the request.

On your own server, send the person's access token from the browser, as Supabase's page describes: put it in an Authorization: Bearer header on your request, read it on the server, and call supabase.auth.getUser(token) with that value. Your project's key never goes into that call.

Two fixes that make it worse

Both get suggested for this error, and both leave you worse off than the error did.

Swapping in the secret key. It cannot help, because service_role and sb_secret_ carry no sub either, so Auth answers the same way. The harm is in where the key ends up. Ask a builder to "use the service key" to get past an error and it may well put it in the page your visitors download, where it reads and writes every row in your database whatever your rules say. If that has already happened, rotate it first.

You can see that mistake from outside. Our free scan reads your live app's JavaScript the way a stranger would and names every key it finds, including whether it is one that belongs there. It takes about 20 seconds and needs no account: scan your app.

Deleting the check and taking the user's id from the request. A function that reads userId from the body believes whoever sends it. If that function also uses the secret key to write, anyone who opens your page and reads how the function is called can act as any of your users, and nothing in your logs says it was not them. With the check left in, a stranger who tries the same call gets this error back and nothing else.

The other messages from the same check

Auth's checks run in a fixed order and each one has its own sentence, so the message tells you how far the token got.

MessageStatus and codeWhat happened
This endpoint requires a valid Bearer token401 no_authorizationNo Authorization: Bearer header arrived at all.
invalid JWT: unable to parse or verify signature, …403 bad_jwtThe stamp is wrong: a damaged token, one from another project, or an old key.
invalid claim: missing sub claim403 bad_jwtThe stamp is right and the name line is blank: an API key.
invalid claim: sub claim must be a UUID400 bad_jwtA name is there, and it is not in the form of a Supabase user id.
User from sub claim in JWT does not exist403 user_not_foundThe token names a user who has since been deleted.
Session from session_id claim in JWT does not exist403 session_not_foundThe token outlived its session, for example after a sign-out on another device.

Every row comes from the same file, internal/api/auth.go. Your database's own version of the wrong-stamp row, PGRST301, has a post of its own. The session row is the one to read alongside why Supabase users get logged out.

Keeping the check in place after the next change

A check on who is calling can disappear the next time you ask your builder to make an error go away, and the two quickest ways to silence this one are the two above.

Reeve Monitor re-scans your live app every hour:

  • all nine checks every hour, on up to three apps
  • an email the day a re-scan grades your app lower than the one before; a secret key in your JavaScript caps the grade at D on its own
  • whether the app is up, every 60 seconds
  • a monthly report of what it saw

A function that trusts the user id it is sent can change any of your users' rows for whoever calls it. Reeve Care keeps a copy of your Supabase database from before that happens.

  • an encrypted copy on a schedule, kept on Reeve's own storage in the EU, outside your Supabase account
  • each copy verified before it counts, by counting the rows in every table
  • a one-click restore that saves what is there now before it replaces anything
  • your uploaded files as well, once you connect a Storage credential
  • everything Monitor does

What to do now

What to do

  • Open the failing request in the Network tab. If Authorization carries the same value as apikey, nobody was signed in when it was sent.
  • Call functions that need a person only after sign-in, and have the function answer 401 when getUser() finds no user.
  • On your own server, pass the person's access token to getUser(). Your project's key has no user in it.
  • Leave the secret key where it is. It names nobody either, and in the page it reads and writes every row.
  • Keep the check on who is calling. A function that takes the user's id from the request believes anyone.

If your app shows Edge Function returned a non-2xx status code while this message sits in the log, the function is passing the refusal on, and that post reads the reply from there. The 10-minute security checklist covers what else a newly launched app tends to leave open.

FAQ

What does "invalid claim: missing sub claim" mean in Supabase?

It means Supabase Auth was asked which user is calling and was handed a token with no user in it. The token itself was genuine and passed the signature check. What it lacked was the sub claim, the field that holds a user id. That is what your project's API keys look like, which is why the usual cause is a key sent where a signed-in user's token belongs.

Is it the same as "Auth session missing!"?

No. "Auth session missing!" comes from supabase-js in the browser, which finds no session and does not send the request at all. "invalid claim: missing sub claim" comes back from Supabase Auth, which received a request and found a key where the user's token should be. The first usually means a signed-out page; the second usually means a function or server passed the key on.

Will turning off Verify JWT fix it?

No. Verify JWT is a check Supabase runs on an Edge Function before your code starts, and this message comes later, from Supabase Auth, when your code asks who is calling. Turning the platform check off lets requests with no token at all reach your code as well, and it leaves this error exactly where it was.

Does an anonymous sign-in cause this error?

No. An anonymous sign-in creates a real user with an id of its own, so its token carries a sub claim and is_anonymous set to true. The anon key is a different thing that shares the name: a project key with no user in it, and it is the one that produces this message.

Why do some pages say 401 and others 403?

Supabase's troubleshooting page for this message has 401 in its title. The current Supabase Auth code answers 403, with the error code bad_jwt. If your code decides what to do from the status alone, check the error code and the message as well.

Written by

Vlad Tkachenko

Founder, Reeve

I spend my time looking at apps built with Lovable, Bolt, v0, Cursor and Replit, and at the short list of mistakes that keep turning up in them.

More about the author

Read next

All articles

Back up your Supabase database before your next change

Reeve keeps an encrypted copy on a schedule and restores it for you if something breaks.

See Supabase backups

Automated external check, not a full audit. Absence of findings is not a guarantee of safety.