Sessions and JWTs
The idea in one sentence
Section titled “The idea in one sentence”A successful sign-in gets you a short-lived JWT access token plus a longer-lived refresh token, supabase-js stores and silently refreshes both for you, and it is the claims inside that JWT — decoded and inspected for real below — that Postgres uses to decide what a request is allowed to see.
A real local access token, fully decoded
Section titled “A real local access token, fully decoded”Signing in returns a session containing an access_token (a JWT) and a refresh_token. Decoding a real one issued by this course’s local GoTrue — header.payload, signature omitted — shows every claim auth.jwt() can read inside an RLS policy:
header: {"alg":"ES256","kid":"b81269f1-21d8-4f2e-b719-c2240a840d90","typ":"JWT"}payload: { "iss": "http://127.0.0.1:55321/auth/v1", "sub": "64721d1e-dd1d-4672-bdb0-519dcde3c455", "aud": "authenticated", "exp": 1790660782, "iat": 1790657182, "email": "[email protected]", "app_metadata": {"provider": "email", "providers": ["email"]}, "user_metadata": {"email": "[email protected]", "email_verified": true, "phone_verified": false}, "role": "authenticated", "aal": "aal1", "amr": [{"method": "password", "timestamp": 1790657182}], "session_id": "dab49039-ccbe-4cc9-a57d-1250ece5e491", "is_anonymous": false}Each claim has a specific job: sub is what auth.uid() reads; role is what auth.role() reads and what Postgres/PostgREST actually set roles to; aud (audience) must be authenticated for PostgREST to accept the token at all; exp/iat are standard JWT expiry/issued-at Unix timestamps; session_id ties the token to one specific sign-in session (used by refresh rotation, below); aal (Authenticator Assurance Level) is aal1 for a single factor and aal2 once MFA has been completed; amr (Authentication Methods Reference) is an array recording how the user authenticated and when — password here, but a second entry would appear if a session was later upgraded with MFA; is_anonymous is what lets a policy exclude anonymous sessions specifically (proved in the previous lesson).
Asymmetric signing: the real default since October 1, 2025
Section titled “Asymmetric signing: the real default since October 1, 2025”Per the changelog, this is not an opt-in feature description anymore — it is a completed default: “Starting October 1, 2025, all new projects will use asymmetric JWTs by default. Existing projects can opt-in at any time.” (supabase.com/blog/jwt-signing-keys, 2025-07-14). The real header above already shows it: "alg":"ES256" with a kid (key ID) — an asymmetric elliptic-curve signature, verifiable with a public key, not a shared secret. The local stack’s real /.well-known/jwks.json confirms the same key:
curl -s "$API_URL/auth/v1/.well-known/jwks.json"{"keys":[{"alg":"ES256","crv":"P-256","ext":true,"key_ops":["verify"],"kid":"b81269f1-21d8-4f2e-b719-c2240a840d90","kty":"EC","use":"sig","x":"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4","y":"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ"}]}Refresh token rotation: a real reuse-detection error
Section titled “Refresh token rotation: a real reuse-detection error”Every successful /auth/v1/token?grant_type=refresh_token call returns a new refresh token and invalidates the one just used — rotation, not reuse. refresh_token_reuse_interval (10 seconds on this local stack’s config.toml) is a short grace window: presenting the immediately-previous refresh token again within that window is tolerated (returns the same next-generation session again, rather than erroring — real apps retry requests, and a network hiccup should not cost a session). Proof of the real boundary: using a refresh token that is not merely the last one, but two generations stale:
# token_1 -> refresh -> token_2 (rotation, succeeds)# token_2 -> refresh -> token_3 (rotation, succeeds)# token_1 (now 2 generations old) -> refresh again:curl -s -X POST "$API_URL/auth/v1/token?grant_type=refresh_token" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -d '{"refresh_token":"<token_1, already superseded twice>"}'{"code":400,"error_code":"refresh_token_already_used","msg":"Invalid Refresh Token: Already Used"}A real, specific refresh_token_already_used error — not a generic 401. On this local GoTrue configuration, presenting the stale token is rejected on its own, but the still-current refresh token from the legitimate chain kept working immediately afterward in this same proof session — worth stating precisely rather than assuming: this local behavior rejects the reused token itself without necessarily revoking the entire active session family, so do not assume “reuse detected” always means “every token for this session is now dead” without checking your own project’s exact behavior.
getSession() vs getUser(): proof with a tampered token
Section titled “getSession() vs getUser(): proof with a tampered token”getSession() reads the session directly from local storage and does not verify the JWT’s signature — it trusts whatever is stored. getUser() sends the token to the real Auth server (GET /auth/v1/user), which verifies the signature cryptographically before returning anything. The difference matters most exactly when a token has been tampered with. Take a real, valid access token and change its sub and role claims client-side, keeping the (now-mismatched) original signature — exactly what getSession()-style local decoding does, and exactly what a real attacker controlling client storage would try:
# (Node one-liner: decode the real token, flip sub to Bob's uid and role to# service_role, re-encode header.payload, keep the OLD signature untouched)getSession()-style (local decode only, NO verification):{"sub":"8f07c3e8-0ce3-4d88-b1b7-75c93cca74ec","role":"service_role", ...} ^ the tampered claims are trusted completely — this is what a client-side check reading getSession()'s cached JWT would see
getUser()-style (GET /auth/v1/user with the SAME tampered token):{"code":403,"error_code":"bad_jwt","msg":"invalid JWT: unable to parse or verify signature, token signature is invalid: crypto/ecdsa: verification error"}The tampered token claims to be service_role and a different user entirely — and a piece of code that only ever called getSession() and trusted its .user field would believe it. The real Auth server rejects it outright, because the signature no longer matches the payload it was issued over. This is the entire reason getUser() (or its faster modern equivalent, getClaims() below) — never getSession() alone — must gate anything that matters: an admin check, a paid-feature gate, a server-side redirect decision.
@supabase/ssr: the cookie pattern
Section titled “@supabase/ssr: the cookie pattern”A browser-only client keeps its session in localStorage, which a server component or route handler cannot read. @supabase/ssr bridges this with a cookie-based client that both browser and server code construct the same way, reading/writing the same cookie:
import { createServerClient } from '@supabase/ssr';import { cookies } from 'next/headers';
export async function createClient() { const cookieStore = await cookies();
return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return cookieStore.getAll(); }, setAll(cookiesToSet) { try { cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options) ); } catch { // called from a Server Component with no way to set cookies — // safe to ignore if middleware is refreshing the session } }, }, } );}setAll is called whenever the library needs to write cookies back — most often right after a token refresh, since a rotated refresh token must reach the browser or the next request re-presents a now-superseded (and, per the proof above, rejected) token. The session cookie is named sb-<project-ref>-auth-token by default (supabase.com/docs/guides/auth/server-side/creating-a-client) — one cookie, read identically by a Server Component, a Route Handler, and middleware, all constructing the same createServerClient against the same request’s cookies.
auth.jwt() inside RLS, and exp handling
Section titled “auth.jwt() inside RLS, and exp handling”The previous two lessons proved auth.uid(), auth.role(), and auth.jwt() returning real values and used a tenant_id custom claim inside a policy condition — the exact same JWT decoded above is what those functions read from, server-side, on every request. exp needs no manual handling anywhere in this chain: PostgREST and GoTrue both reject an expired token’s signature check at the HTTP layer before a policy ever runs, and supabase-js’s getSession() proactively refreshes a token nearing exp before returning it, so application code normally never has to compare exp to the current time itself — the one exception being exactly the tampered/expired-token edge cases this lesson proves, where bypassing the SDK (a raw fetch, a hand-rolled decode) also bypasses that automatic handling.
flowchart LR
signin["User signs in"] --> issue["Auth issues ES256 access token + refresh token"]
issue --> attach["supabase-js attaches JWT to every API request"]
attach --> pg[("Postgres / PostgREST reads JWT claims")]
pg --> uid["auth.uid() / auth.role() / auth.jwt() available inside RLS policies"]
issue --> rotate["Refresh rotates the token; reuse of a stale one: real error"] Reading AI-generated code
Section titled “Reading AI-generated code”An AI assistant asked to “protect this admin dashboard route on the server” produced this:
🐛 Spot the bug
import { createClient } from '@/lib/supabase-server';import { redirect } from 'next/navigation';
export default async function AdminLayout({ children }: { children: React.ReactNode }) {const supabase = await createClient();const { data: { session },} = await supabase.auth.getSession();
if (!session || session.user.app_metadata?.role !== 'admin') { redirect('/login');}
return <>{children}</>;}Show the answer
This looks reasonable — it checks for a session and an admin role before rendering. The bug is getSession() itself in a server-side security gate: it never verifies the JWT’s signature, it just reads whatever claims are sitting in the session cookie. Proved directly above — a tampered token with a forged role/claims payload sails straight through a getSession() check with the forged values intact; only sending the token to the real Auth server (getUser()) or verifying it cryptographically (getClaims()) would catch the forgery, exactly as proved by the real bad_jwt signature-verification error earlier in this lesson. The fix is a one-line swap: const { data, error } = await supabase.auth.getUser(); if (error || !data.user || data.user.app_metadata?.role !== 'admin') redirect('/login'); — same logic, but now backed by a real, server-verified signature check instead of trusting whatever bytes happen to be in a cookie.
Mental Model
Section titled “Mental Model”A JWT is a claim plus a signature — the claim is just JSON, freely readable and freely editable by anyone holding the bytes, and the only thing standing between “freely editable” and “trustworthy” is whether something actually re-checks the signature before believing the claim. getSession() skips that check by design (it is meant to be fast and local, for UI purposes like “show the logged-in nav bar without a network round trip”); getUser()/getClaims() do the check, at the cost of either a network call or a local cryptographic verification against a public key. Every proof in this lesson is really one idea wearing different clothes: a signature that does not match its claims is worthless no matter how official the claims look, and Postgres/PostgREST/GoTrue only ever trust a JWT after checking that signature — never by reading the JSON and hoping.
Trade-offs
Section titled “Trade-offs”- Asymmetric (
ES256) signing lets any service verify a token locally with a public key, no shared secret distributed anywhere — at the cost of one more moving part (a JWKS endpoint, key rotation) compared to a single sharedHS256secret. - Refresh token rotation with a short reuse-grace window tolerates real-world network retries — at the cost of a real attacker who steals a refresh token also getting that same short grace window before reuse is flagged, not zero.
getSession()being local-only makes it fast enough to call on every render for UI purposes — at the cost of it being actively unsafe as a security boundary, proved directly above.- Cookie-based
@supabase/ssrsessions work uniformly across Server Components, Route Handlers, and middleware — at the cost of thesetAll/cache-header plumbing needing to be exactly right, or sessions silently desync between browser and server (the docs’ own repeated warning about randomly logged-out users whengetClaims()/refresh handling is wired incorrectly).
Common Misconceptions
Section titled “Common Misconceptions”- “Asymmetric JWT signing keys are still an opt-in migration.” Corrected here with the exact date: as of October 1, 2025, every new Supabase project defaults to asymmetric signing; only pre-existing projects need to opt in.
- “
getSession()andgetUser()return the same trustworthiness, just different speeds.” Proved false: a tampered token’s forged claims passgetSession()completely unnoticed and are rejected outright bygetUser()— the speed difference is real, but so is the trust difference, and only one of them is a valid server-side security check. - “The
anonandservice_roleAPI keys use the same signing scheme as real user sessions.” Proved false by decoding both: the static keys are legacyHS256with nokid, while a real session token isES256with akidmatching the live JWKS endpoint — two different mechanisms coexisting on the same project. - “A refresh token that gets ‘reused’ immediately always throws an error.” Proved false: a short, configurable grace window (
refresh_token_reuse_interval) exists specifically so an immediate retry of the same request does not cost the user their session — only a genuinely stale (multiple-generations-old) token triggers the real reuse error.