JWT expired
Requests fail with "JWT expired" because the user's access token outlived its lifetime and wasn't refreshed. Why refresh gets skipped and how to fix it in browsers, mobile apps and servers.
What you see
A request that worked a while ago now fails. From the Data API, with HTTP 401:
{ "code": "PGRST303", "details": null, "hint": null, "message": "JWT expired" }
Storage answers with a message like "exp" claim timestamp check failed. On SupaHost, an Edge Function with Require JWT on returns 401 with the header sb-error-code: UNAUTHORIZED_INVALID_JWT.
Why it happens
A signed-in user has two tokens: a short-lived access token (a JWT, one hour by default) and a long-lived refresh token. The client library swaps the refresh token for a new access token shortly before the old one expires. You see "JWT expired" when a request goes out with an access token that was never swapped:
- Your code copied
session.access_tokeninto a variable, a store, a database row or a background job and kept using it. - The browser tab or mobile app was in the background, the refresh timer didn't fire, and the first request after waking used the old token.
- Server-rendered code read the token from a cookie but nothing refreshed it (no proxy or middleware).
- The device clock is wrong, so the client thinks the token is still fresh.
The anon and service_role keys are JWTs too, but they last ten years, so this error is about user sessions, not your keys.
How to fix it
Don't keep access tokens around. Ask the client for the session each time you need the token.
getSession()refreshes it first if it has expired:const { data: { session } } = await supabase.auth.getSession(); await fetch("/api/bookings", { headers: { Authorization: `Bearer ${session?.access_token}` }, });Calls made through the client itself (
supabase.from(...),supabase.storage,supabase.functions.invoke) attach the current token for you.Restart the refresh timer on mobile. In React Native, tie auto-refresh to the app state:
import { AppState } from "react-native"; AppState.addEventListener("change", (state) => { if (state === "active") supabase.auth.startAutoRefresh(); else supabase.auth.stopAutoRefresh(); });Refresh on the server. With
@supabase/ssr, a proxy (or middleware on Next.js 15 and earlier) refreshes the session on each request and writes the new cookies. See Server-side auth and SSR.Don't run long jobs on a user's token. A job that runs for hours, or later, should use the
service_rolekey on the server and record which user it acts for.Retry once. If a request still fails with 401, call
supabase.auth.refreshSession()and retry it once. If that also fails, the refresh token is gone and the user needs to sign in again.
On SupaHost
The access token lifetime is a project setting: Dashboard → your project → Auth tab → Sign-in & security → Access token lifetime. It accepts 300 to 604,800 seconds and defaults to 3,600 (one hour). Click Save sign-in settings. The change applies within about a minute and only affects tokens issued after that.
Raising the lifetime hides refresh bugs, and a stolen token stays valid for longer. Fix the refresh path first.