The answer. supabase.auth.getSession() decodes the session cookie and hands it back. It does not check the signature, so anything in it, including user.app_metadata, can be forged by whoever controls the cookie. The docs are blunt about it: never trust it in server code. Two verified alternatives exist. getClaims() validates the token's signature against the project's published signing keys (a JWKS fetch, cached) with no round trip to Auth on the hot path, and returns the claims; it is the right call for middleware and page guards on projects using asymmetric keys. getUser() sends the token to the Auth server and returns the user record; slower, but it also catches a revoked session. The data layer stays safe either way because RLS re-reads the JWT inside Postgres, so a forged cookie yields an empty UI, not a leak; the server code around it is what these calls protect.
The pattern.
const { data, error } = await supabase.auth.getClaims(); // signature verified, ~0 network
if (error || !data) redirect("/login");
const { sub, role, app_metadata } = data.claims; // safe to branch on
if (!(app_metadata.tenants as string[] | undefined)?.includes(tenant)) notFound();
getSession() cookie decode, unverified → redirect decisions only
getClaims() local signature check → guards, tenant checks, feature gates
getUser() Auth server round trip → sensitive actions, revocation-aware
Watch out.
- Claims go stale until the token refreshes (about an hour). A role removed a minute ago is still in the JWT; sensitive mutations should re-check the membership table, which is what RLS does for you.
getClaims()verifies Supabase Auth tokens only; a token from your own issuer needs your own verification.- The admin (secret-key) client has no session; calling
getUser()on it returns nothing useful and is a sign two clients got mixed up.
Related: ssr-one-server-client-per-request-cookies-refresh · verify-supabase-jwts-in-a-worker-with-jose-and-jwks · custom-claims-through-the-access-token-hook-rbac