Skip to content

Security basics

Supabase invalid JWT: which key stopped working?

A Supabase invalid JWT error is PGRST301, and it means a signed token failed verification. On a newer project that token is your user, not your API key.

Vlad Tkachenko11 min read
A sealed letter in front of a signet block whose face no longer matches the seal, with a rack of further letters behind an amber bar.

In short

  • A Supabase invalid JWT error arrives as PGRST301 with HTTP 401, and it means the token your request carried failed its signature check. Nothing was looked up and refused.
  • Publishable and secret keys are short strings with no signature on them, so on a project that has moved to them this error is about your signed-in user and never about the API key.
  • PostgREST 13 replaced one vague sentence with a short list of specific ones. Read the message you actually got, because each of them rules out something different.

Your app worked yesterday. This morning every request comes back 401, nothing in your code has changed, and the reply is talking about a JWT, which is not a thing you have ever typed into anything.

{
  "code": "PGRST301",
  "message": "JWT cryptographic operation failed"
}

A Supabase invalid JWT error is the one that turns up after you have done the right thing. You rotated a key because a guide told you to, or you switched your project onto the newer keys, and the app went quiet.

Here is the part that guide after guide gets wrong: the key in this message is often not the key you just changed. Search the error and you will be told to go and check your anon key. On a project that has moved to publishable and secret keys, the API key is not a signed token at all, so whatever failed verification was something else in the request.

What does a Supabase invalid JWT error mean?

Something in your request was a signed token, and the signature did not check out.

A password is a word somebody looks up on a list. A JWT is not that. It is a letter closed with a wax seal: the person at the door does not look you up anywhere, they hold a signet and check whether the seal on your letter was made by it. If it was, you go through, and nobody reads a list.

A letter sealed with the wrong signet is refused even though every word inside it is correct. And the moment the door changes its signet, every letter already out in the world stops opening, without anybody touching the letters.

Your request is the letter. Your Supabase project holds the signet. A 401 here means the two did not agree.

Nothing is looked up. The seal on the token is compared with the signet your project holds, and a letter whose seal was made by something else is refused whatever is written inside it.

The message changed in PostgREST 13, and the code did not

Match on PGRST301. The sentence beside it depends on which version of PostgREST your project runs, and the newer versions give you a short list of them.

PostgREST's own error reference describes PGRST301 as a provided JWT that could not be decoded or is invalid, at HTTP 401, in both version 13 and version 14. That sentence is the description in the documentation table. It is not what lands in your message field. Reading PostgREST's Error.hs on the main branch, the field carries one of these:

What the message saysWhat it tells you
Empty JWT is sent in Authorization headerThe header was sent and there was nothing in it.
Expected 3 parts in JWT; got 1Whatever was sent is not a JWT. A JWT is three chunks separated by dots.
No suitable key or wrong key typeThe server has no key able to verify a token of this kind.
Wrong or unsupported encoding algorithmThe token names a signing algorithm the server will not use.
JWT cryptographic operation failedThe token is well formed and the signature does not match.
Unsupported token typeThe token is of a shape PostgREST does not read.

JWT cryptographic operation failed is the one that means a stale key. The token was built correctly and signed by something the project no longer holds, which is precisely what a rotation leaves behind.

Older projects see something very different, and this is why one particular string is all over the search results. Before version 13, PostgREST printed whatever its JWT library had said, exactly as the library said it. That is where JWSError JWSInvalidSignature comes from: it is a library value printed to the screen, not a sentence anybody wrote for you. PostgREST's changelog for 13.0.0 describes the change as diagnostic error messages instead of exposed internals.

One neighbouring code is worth knowing while you are here. PGRST303, also 401, is what you get when the signature verified and the contents of the token were refused: expired, not valid yet, or carrying the wrong audience. If that is the code you have, stop looking at your keys and go and look at the clock and the session.

Which key is it: your API key or your user's token?

It depends on whether your project still uses anon and service_role.

Supabase's API keys documentation is explicit about the difference. The legacy anon and service_role keys are long-lived JWTs, which is why they begin eyJ. The newer publishable and secret keys are short strings and are not JWTs at all.

That one fact splits this error in two:

  • Your project still uses anon and service_role. The API key in your request is itself a signed token, so a key from another project, a key from before a rotation, or a truncated paste all fail the signature check in exactly the way a bad session would. Either of the two tokens in the request can be the one that failed.
  • Your project has moved to sb_publishable_ and sb_secret_. The API key has stopped being a token, so there is no signature on it for anything to reject. What is left to verify is your signed-in user's access token, which Supabase Auth issues separately from the API key.
On a legacy project two sealed letters arrive and either can be the one refused. Move to the newer keys and the API key stops carrying a seal, so your signed-in user's session is the only thing left with a signature on it.

The practical result is that the first question to ask is not which key is wrong. It is whether anybody was signed in when the request failed. If a signed-out page works and a signed-in page does not, you are looking at sessions. If everything fails, including a page that asks for nothing personal, you are looking at the API key, and your project is a legacy one.

If it is sessions, and your auth log also says Invalid Refresh Token, your users are being signed out because the session behind their token ended.

If the reply came from Supabase Auth and reads invalid claim: missing sub claim, the token passed its signature check and named nobody. That is a key sent where a person's token belongs, and that message has a post of its own.

Invalid JWT is not "No API key found in request"

These are two different messages with two different causes, and the fixes have nothing in common.

An invalid JWT means a token arrived and the signature did not match. "No API key found in request" means no apikey header arrived at all, so the request was turned away at the gateway before your database was asked for anything.

The reply itself settles which you have. A missing key is refused by the gateway in front of your project, so the body carries a message and a hint and no code at all. An invalid JWT got far enough in to be read by PostgREST, which is why it comes back with PGRST301 written on it.

Where the old key is still sitting, in order

Four places, and they are worth checking in this order because that is roughly how often each one is the answer.

  1. Your builder's project settings. Lovable, Bolt, v0 and Replit each keep their own set of environment variables, and if you built with Cursor or Claude Code the same set lives wherever you deploy. This is the copy most people update, and the only copy most people update.
  2. A key written straight into the code. It ships inside the JavaScript your visitors download, so changing it in the dashboard changes nothing until the file is edited and the site is rebuilt. Where that page is, and what each value on it does, if you have not opened it.
  3. Edge Function secrets. A function keeps its own set, separate from your frontend's, so it goes on using the old key after everything else has moved. Supabase also warns that a function with its Verify JWT setting on can break when you rotate a signing key.
  4. A deployment that nobody rebuilt. The key was baked in when the site was last built, so the running copy keeps sending it until something triggers a new build. Your dashboard will show the new key throughout, which is what makes this one hard to see: the place you go to check is the one place that is already correct.

Which key your live app is actually shipping

Open the published site rather than the preview, and read what the page sends.

A legacy Supabase key is a long string beginning eyJ. The newer ones announce themselves in their first few characters: sb_publishable_ belongs in your app, and sb_secret_ never does. If you find the second one in a page a visitor can load, stop reading this article and rotate it before you do anything else. Which of the two you are holding, and what changed when Supabase renamed them, is the thing to settle before you start swapping values.

To see what your project currently issues, every key now lives in one place in the dashboard, under Settings and then API Keys, legacy keys included.

If you would rather not read a minified bundle, our free scan reads your live app the way a stranger would and reports the keys it can see from outside. It takes about 20 seconds and needs no account: scan your app.

Rotating a signing key without signing everybody out

Which of the two rotation paths you are on decides whether your users notice.

Supabase's signing-keys documentation sets them side by side. Rotating the legacy JWT secret signs currently active users out immediately, and anon and service_role have to be rotated at the same time. The newer JWT signing keys work differently: a new key starts as a standby, rotating makes it the current one, and the key it replaced becomes previously used. Signatures from both keys go on being accepted while that is true, so letters sealed last week still open. Revoking the old key is the separate step that stops accepting them.

Two warnings in that same documentation are the ones that catch people:

  • A component that verifies tokens itself against the legacy JWT secret, with a library such as jose or jsonwebtoken, breaks on the rotation. It is holding the old signet and nothing told it to change.
  • An Edge Function with Verify JWT switched on can break for the same reason.

Creating publishable and secret keys does not revoke your legacy keys either. They keep working until you disable them, which the documentation calls out as a separate step. So an app that moved half its copies over goes on working for weeks, and the day somebody disables the old pair is the day the remaining half starts answering 401.

Watching an app that has started answering 401

An app that returns 401 to every request is still up, still serving a page, and still answering a health check. Nothing in your dashboard turns red. You find out when somebody writes to you.

Reeve Monitor finds out first:

  • 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
  • whether the app is up, every 60 seconds
  • a monthly report of what it saw

A key you had to rotate in a hurry is usually a key that was somewhere it should not have been, and from here you cannot see what was read or changed with it while it was out. Reeve Care keeps a copy of your Supabase database from before that happened.

  • 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 today

What to do

  • Read the code field, not the sentence. PGRST301 is a signature that did not match; PGRST303 is a token whose contents were refused, such as an expired one.
  • Work out which token failed before you change a key. If signed-out pages work and signed-in pages do not, it is a session rather than your API key.
  • Check all four places the old key hides: builder settings, the code itself, Edge Function secrets, and a deployment nobody rebuilt.
  • Do a rotation in one sitting. Every copy you leave behind is an app that half works, and the running site keeps sending the old key until it is rebuilt.
  • On the newer signing keys, revoking is the step that actually stops the old signatures. Rotating alone leaves them accepted.

Start by asking whether a signed-out page still loads, because that one answer decides which half of this article applies to you. If it turns out a secret key was out in the open, rotating it covers the order to work in, and which keys were ever safe to ship covers how the pair came to look so alike in the first place.

FAQ

What does PGRST301 mean?

PGRST301 is PostgREST answering HTTP 401 because a signed token in your request could not be verified. PostgREST is the server that turns your Supabase tables into the HTTP API your app talks to. Its own error reference describes the code as a provided JWT that could not be decoded or is invalid, and from version 13 onward the message field carries a more specific sentence saying which part of the check failed.

Why do I get 401 after rotating my Supabase key?

Because a copy of the old key is still being sent. The usual places are your builder project settings, a key typed straight into the code, an Edge Function secret, and a deployment that was built before you made the change and has not been rebuilt since. A token signed by a key the server no longer accepts fails verification, which reads as a 401 on every request rather than as a message about the key.

What is the difference between an invalid JWT and a missing API key?

They are different messages with different causes. "No API key found in request" means the request arrived with no apikey header at all and was turned away before your database was asked anything. An invalid JWT means a token was present and its signature did not check out. The first is a request that was not addressed; the second is a request that was addressed and sealed wrongly.

Do I have to update my Edge Functions too?

Yes, and they are the copy people forget, because an Edge Function keeps its own secrets, separate from the ones your frontend uses. Supabase also warns that a function with its Verify JWT setting switched on can break when you rotate a signing key, and that any component verifying tokens itself against the legacy JWT secret will break for the same reason.

How do I see which key my live app is using?

Open your published site, not your preview, and look at what the page actually sends. A legacy Supabase key is a long string beginning eyJ, and the newer ones begin sb_publishable_ or sb_secret_. Our free scan reads a live app the way a stranger would and reports the keys it can see from outside, which also tells you whether an old one is still being shipped.

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.