Fix Supabase JWT Expired (PGRST301) in Next.js
TL;DR — A Supabase access token is a JWT that expires ~1 hour after issue. When you query a table with an aged-out token, PostgREST returns HTTP 401 with { "code": "PGRST301", "message": "JWT expired" }. In the browser this is rare because supabase-js auto-refreshes. On the server (SSR, App Router, route handlers) nothing holds a refresh timer, so the cookie goes stale and every request 401s after an hour. The fix: add @supabase/ssr middleware that calls getClaims() on each request to revalidate and refresh the token, then returns the same response object so the new cookies reach the browser. Full code below.
Why do I get PGRST301 "JWT expired"?
Supabase's data API is PostgREST. Your access token is a short-lived JWT — default expiry is 3600 seconds (JWT expiry limit in Auth settings). Alongside it, the session carries a long-lived refresh token that gets exchanged for a fresh access token.
When PostgREST receives a request whose access token has passed its exp, it rejects it:
{ "code": "PGRST301", "message": "JWT expired", "details": "Unauthorized", "hint": null }
One nuance worth knowing: the generic PostgREST error reference documents PGRST301 as "Provided JWT couldn't be decoded or it is invalid," but Supabase's hosted runtime returns the human-readable JWT expired message for the expiry case specifically. Different SDKs (Flutter, C#, JS) all surface the same code. So match on code === 'PGRST301', not the message string — the code is the stable signal, the wording is not.
The real question is why the token aged out without anything refreshing it. That splits cleanly into client vs. server.
Browser vs. server: where the session actually dies
In the browser, createClient defaults to autoRefreshToken: true and persistSession: true. supabase-js runs a background timer that refreshes the access token before it expires and fires onAuthStateChange('TOKEN_REFRESHED', …). A live SPA tab almost never 401s — unless the tab was backgrounded or the machine slept past expiry, in which case the next query can fire before the refresh completes and you eat one PGRST301. That is the classic "I left the tab open overnight" report. You do not need to write your own refresh loop; doing so causes races.
On the server, there is no long-lived JS runtime holding a timer. Every request is stateless and the session lives entirely in cookies. If nothing refreshes those cookies, the access-token cookie goes stale and every server-side query 401s — and eventually the client 401s too once it reads the stale cookie. This is the gap @supabase/ssr middleware exists to close. If you skipped the middleware and your Server Components start throwing PGRST301 exactly an hour after login, this is your bug.
How do I refresh the Supabase session in Next.js?
Install @supabase/supabase-js and @supabase/ssr. If you are still on @supabase/auth-helpers-nextjs, it is deprecated — migrate to @supabase/ssr. The server client uses a getAll / setAll cookie interface (the older per-cookie get/set/remove interface is superseded). setAll is invoked whenever the library needs to write cookies — including right after a token refresh — so the response has to carry those cookies back.
utils/supabase/middleware.ts:
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// Do NOT run code between createServerClient and getClaims().
// This call revalidates the token and, if expired, refreshes it —
// writing the new cookies via setAll above.
const { data } = await supabase.auth.getClaims()
const user = data?.claims
if (
!user &&
!request.nextUrl.pathname.startsWith('/login') &&
!request.nextUrl.pathname.startsWith('/auth')
) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
// Return supabaseResponse as-is. The refreshed cookies live on it.
return supabaseResponse
}
middleware.ts (project root):
import { type NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/middleware'
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
Two rules here are load-bearing, and both are documented causes of "random logout" bugs:
- Put nothing between
createServerClientandgetClaims(). Inserting logic there breaks the refresh timing. - Return the same
supabaseResponse(or copysupabaseResponse.cookiesonto any new response you build). The refreshed cookies live on that object; drop it and the refresh is silently lost, so the browser and server desync and you get intermittent logouts.
getClaims vs. getUser vs. getSession
Which method you call for auth decisions matters as much as the middleware:
getSession()— never trust it for authorization in server code. On the server it reads the session straight from the request cookies and does not verify the JWT, so a stale or forged cookie can pass. Use it only when you genuinely need the raw access/refresh token strings.getUser()— authoritative, but a network round trip. It hits the Auth server, returns the user record, and revalidates. Reach for it when you need fresh user data or revocation-accurate checks (e.g., detecting a forced logout).getClaims()— the current recommended default for protecting pages and data. It verifies the JWT signature locally via WebCrypto against your project's published JWKS (the default for new projects using asymmetric signing keys), so it is fast and still trustworthy. On legacy symmetric (HS256) keys it falls back to callinggetUser(). Caveat: it validates signature and expiry locally, so it will not detect a session revoked server-side — usegetUser()when that matters.
Trusting getSession() on the server is exactly the kind of mistake a static scan can catch — more on that below.
"JWT expired" is not "missing sub"
Do not conflate these two 401s. They have different causes and refreshing fixes only one:
| Symptom | Meaning | Fix |
|---|---|---|
PGRST301 + JWT expired, 401 | Token was valid, aged out | Refresh the session (SSR middleware, or let autoRefreshToken handle it client-side) |
401 "invalid claim: missing sub" | Wrong token entirely — JWT has no sub | Stop sending the wrong token |
missing sub almost always means you passed an anon/publishable or service/secret API key where an Auth access token was expected. Those are valid API keys but carry no user id. Refreshing does nothing — there is no user session to refresh. Send the user's access token (Authorization: Bearer <user_jwt>) or initialize the server client so it reads the session cookie.
One more diagnostic to rule out: clock skew. JWT exp/nbf/iat are checked against wall-clock time. A drifted server clock (VM suspend/resume, missing NTP) can read a valid token as expired and produce spurious 401s. Keep NTP synced.
FAQ
Why does my session work for an hour then break?
Your access token expires at 3600s and nothing is refreshing the server-side cookie. Add the @supabase/ssr middleware above. This is the single most common cause of "Supabase session not persisting" in Next.js.
Do I need to call refreshSession() manually?
Client-side, no — autoRefreshToken handles it and a manual loop causes races. Server-side, the middleware's getClaims() call triggers the refresh for you. You rarely call refreshSession() by hand.
Why do I still get PGRST301 right after the tab wakes from sleep?
The refresh timer paused while the tab was backgrounded, so a query can fire in the gap before the token refreshes. Retry once on code === 'PGRST301'; the refreshed token arrives moments later.
Is getSession() ever safe on the server?
Only for reading the raw token strings — never for deciding whether a user is authenticated. Use getClaims() or getUser() for that. Understanding how the JWT reaches your queries also underpins RLS behavior, covered in the complete guide to Supabase Row Level Security.
My RLS policies suddenly deny everything after login — same bug?
Possibly related. If the token never reaches Postgres, auth.uid() comes back null and policies fail closed. That is a distinct failure mode worth checking — see why auth.uid() returns null under RLS. If you are wiring a third-party issuer, Clerk's token template has its own refresh timing notes in the Clerk + Supabase RLS setup.
Where GuardLayer fits (and where it doesn't)
Let's be precise, because token expiry is fundamentally a runtime problem. GuardLayer is a static scanner — it reads your source, not your live session cookies. It cannot see that a token expired, cannot watch a refresh happen, and cannot tell whether your middleware is actually running in production. If your 401 is a stale cookie at request time, no static tool will observe it. The fix is the middleware above, full stop.
What GuardLayer can catch is one authorization mistake this post warned about — trusting getSession() in server code. Its supabase/getsession-server-trust rule flags a middleware.ts that calls supabase.auth.getSession() but never calls getUser() or getClaims() to revalidate — the exact pattern where a forged or stale cookie can spoof the auth check. That check is scoped to your middleware file (it does not scan every route handler), and it stays quiet the moment the same file also revalidates. It will never tell you your token expired — but it will tell you the auth gate can be fooled by a cookie, which is the more dangerous cousin of the bug you came here to fix.
Catch this before it ships — free
GuardLayer scans every push for this and 23 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.