PostgREST · PGRST301
JWT expired (PGRST301) — refresh handling in Supabase
Luca Urti
JWT expired
Why do I get “JWT expired” after leaving my app open?
Supabase access tokens are short-lived — one hour by default — and are meant to be exchanged for a new one using the refresh token before they run out. The error means the request carried an access token past its expiry, which happens when nothing performed that exchange: the browser tab was suspended and the client's auto-refresh timer never fired, a server-rendered request read a stale cookie, or a token was captured once and reused later by a background job. The short lifetime is a security property, not a limitation — it bounds how long a leaked token is useful. Note that PGRST301 is PostgREST's code for any JWT verification failure, not for expiry specifically, so the same code arrives for a token signed with the wrong secret or malformed in transit — the message body is what tells the three apart.
Where it breaks in practice
In the browser the Supabase client refreshes on a timer and on tab focus, so this error usually means the client was created with persistence disabled, or a second client instance exists that does not share the session storage. Creating createClient inside a React component body, so a new client is constructed on every render, is the version of this that appears most often in generated code.
On the server, tokens do not refresh themselves. A framework that reads the session from a cookie during rendering has to write the rotated token back into the response, and a route that only reads cookies will serve an expired token forever. This is what Supabase's server-side helpers exist to handle, and it is the piece most often missing when auth works locally and fails in production.
The one thing that is not the fix
Raising the JWT expiry in the dashboard makes the error rarer without making it go away, and it directly extends the window in which a stolen token is valid. Since these tokens travel to the browser and are stored there, that window is the main thing limiting the damage of an XSS or a leaked log line.
The fix that works and costs you the database
The workaround that removes the error permanently is to stop using the user's token and call the database with the service_role key from a server route instead. The session problem disappears — and so does every RLS policy, because service_role bypasses them all. From that point the route itself is the only thing deciding who may read which row, and if it takes an id from the request and does not check ownership against the session, you have converted an expiring-token nuisance into an IDOR that returns other users' records.
Whether the trap is already in your repo is a question you can answer
Sentris reads the SQL and the client code, so it reports the shortcut above where it was actually taken — a service_role key in a browser bundle, a policy that is using (true), a table with RLS switched off. A scan needs no account and no card. How often it is wrong is measured and published.