JWT expired / Invalid JWT (Supabase Auth)

Filed under Supabase & databases

Most likely causeAbout 10 min · 6 steps

The access token your app sent has passed its one-hour lifetime and nothing refreshed it — usually hand-carried tokens or a missing session-refresh step.

Notice when it happens — about an hour after login
Stop passing tokens by hand; use the client session
Add session refresh (middleware on Next.js)

Fastest route

Give your AI this prompt

Copy it, replace the bracketed part with your error, and send it.

Preview the prompt
I am using Supabase Auth and getting this error: [paste it exactly — "JWT expired", "Invalid JWT", or the full error including any code like PGRST301]. It happens [right after login / about an hour after login / only on certain pages or API routes].

Here is the code that makes the failing request: [paste it]. And here is how my Supabase client is created in this part of the app: [paste the client setup, or say "I don't know"].

Please: 1) tell me whether this is an expired token that was never refreshed, or a token that cannot be verified at all, 2) find anywhere my code stores or sends an access token by hand and replace it with the client's session handling, 3) if this is a Next.js app, check whether session-refresh middleware exists and add it if it is missing.

Constraints: do not fix this by raising the JWT expiry in settings, do not put any token or service role key in client code, and make one change at a time so we can tell which one worked.

Fix it yourself

Follow the repair

  1. Notice the timing — it is the biggest clue. If everything works right after login and starts failing roughly an hour later, you are watching a normal expiry that never got refreshed; the token system is doing its job and your refresh path is missing. If it fails immediately, even on a fresh login, think wrong token instead: copied from a different project, cut off mid-string, or an API key being sent where a user token belongs.
  2. Stop carrying tokens by hand. Search your code for places that save an access_token or build an Authorization header manually. Replace that pattern with the Supabase client's own calls — when you query through the client, it attaches the current session token and refreshes it when needed:
    // Fragile: a token you saved earlier will go stale
    fetch(url, { headers: { Authorization: `Bearer ${savedToken}` } });
    
    // Durable: the client attaches the current, refreshed token
    const { data } = await supabase.from("posts").select("*");
  3. Check the session in the exact code path that fails. In the function or route that errors, ask the client who is signed in right now:
    const { data: { session } } = await supabase.auth.getSession();
    console.log(session?.expires_at);   // a past timestamp = dead token
    If the session is null, or its expiry is behind you, that code path never had a live session to send — the request went out with a corpse of a token, or none at all.
  4. On the server, build the client from the request's cookies. In Next.js route handlers and server components, create the client using the @supabase/ssr pattern so it reads the visitor's cookies for this request. Never paste a token from your own test login into an environment variable or a header — that token expires within the hour and, worse, it is your identity being sent for every visitor.
  5. Add session-refresh middleware in Next.js apps. This is the step most often missing. Middleware that runs the Supabase client on each request is what receives refreshed tokens and writes them back into cookies. Without it, the browser may refresh happily while every server-side read keeps finding the original, expired token — which is why the app can look signed in and still fail on every server call.
  6. Sign out, sign in, and test past the old failure point. A fresh login handing you a working app proves only that new tokens work. To prove the fix, stay signed in beyond where it used to break and confirm requests still succeed. If you cannot wait an hour, temporarily watch the session expiry while you work and confirm it rolls forward instead of counting down to failure. And a note on the tempting shortcut: the Supabase dashboard's Auth settings let you stretch the token lifetime beyond an hour. That merely delays the same failure and weakens the protection short lifetimes exist for — treat a longer expiry as a cushion on top of a real refresh fix, never as the fix itself.
Why this happensOptional explanation

When a user signs in, Supabase hands your app two things: a short-lived access token (the JWT) that proves who they are, and a long-lived refresh token whose only job is to trade itself in for a fresh access token before the old one expires. Access tokens are kept short — one hour by default — so that a leaked token goes stale quickly. The system works because the Supabase client library watches the clock and refreshes quietly in the background, and your app never notices.

The errors appear where that quiet machinery is bypassed. AI-built apps often copy a pattern from a tutorial: grab the access token once, store it somewhere, and attach it to requests by hand. That snapshot works beautifully for an hour and then fails forever. Server code has its own version of the trap: a route that reads a token from a cookie that nothing ever refreshes, or a Next.js app where the middleware that writes refreshed tokens back to the browser was never added — so every server request presents the token from login time, ageing in place. "Invalid JWT" is the close cousin: the token cannot even be verified, which usually means it was mangled in copying, truncated, or minted by a different project than the one receiving it.

  • The one-hour pattern: works after login, breaks about an hour later — an unrefreshed token, almost every time.
  • Hand-carried tokens: code that stores an access_token and sends it manually instead of letting the client attach the current one.
  • Stale server cookies: no middleware or refresh step ever writes the new token back, so the server keeps presenting the old one.
  • Wrong-project tokens: a token from a different Supabase project (or a truncated copy-paste) fails verification as Invalid JWT.

Quick answers

Why does it break after about an hour of being signed in?

Because the default lifetime of a Supabase access token is one hour. A refresh token is supposed to quietly trade it for a fresh one before it lapses; when refresh is not wired into the code path making the request — a hand-built fetch, a server route reading a copied token — the first request after the hour mark arrives with a dead token.

Does this mean my API key expired?

No. Your project's anon and service role keys are long-lived API keys, not session tokens. This error is about the signed-in user's access token. Regenerating project keys will not fix it and creates new problems; fix the session handling instead.

My users get logged out randomly. Is that the same problem?

Often, yes. If the session's refresh is failing — missing session-refresh middleware in Next.js, multiple tabs fighting over one refresh token, or an app that stored a token instead of a session — users get bounced to login long before they should. The cure is the same: let the official client own the session everywhere.

One fix, every Friday.

One real error, explained in plain English, with the fix — in your inbox every Friday. No spam, no jargon, unsubscribe anytime.