Supabase getSession vs getUser: stop trusting the cookie on your server
Supabase getSession vs getUser comes down to one rule: on the server, don't trust getSession(), because it reads the session from a cookie without checking it. Use getClaims() to protect pages and data. Reach for getUser() when you need a fresh record from the Auth server or must catch revoked sessions, and keep getSession() for code that needs the raw tokens.
QUICK CHECKLIST
- Replace server getSession() identity checks with getClaims()
- Never read session.user to authorize anything
- Call getUser() before sensitive actions
- Keep authenticated routes dynamic: no ISR, no CDN cache
- Create the Supabase client inside each request
- Keep the JWT secret out of NEXT_PUBLIC_ variables
Supabase getSession vs getUser vs getClaims: the short version
Supabase Auth has three ways to ask who the current user is.
getSession()loads the session from storage. On the server that's the cookie, and nothing is re-validated.getClaims()verifies the signature of the access token (a JWT, the signed token that proves who is logged in) and returns its claims, such as the user id (sub) and expiry (exp).getUser()calls your project's Auth server and returns the current user record.
Supabase's docs warn against using the user object from getSession() for authorization decisions.
| getSession() | getClaims() | getUser() | |
|---|---|---|---|
| What it does | Reads the cookie | Verifies the JWT | Asks the Auth server |
| Safe to authorize with | No | Yes | Yes |
| Network call | No | Only for keys or refresh (always on HS256) | Every call |
| Sees a revoked session | No | Not until expiry (asymmetric keys) | Yes |
| Best for | Forwarding tokens | Protecting pages and data | Fresh user record |
Based on Supabase's SSR and JWT docs. Check the current docs for your version.
How a forged cookie gets past getSession(), and the fix
On the server, the session comes from the cookie the browser sends. The browser belongs to whoever uses your site, including an attacker. Anyone can forge the session cookie, and Supabase's docs say that trusting it without verification lets an attacker render another user's page.
The attack path is short:
- The attacker edits the user id inside their own cookie.
- Your server reads it with
getSession()and believes it. - Your code uses that id for an admin check, a page or a query.
Queries through the user's own client still carry a token that Supabase checks before applying row level security (RLS), the database rules that limit which rows each user can read. The danger is anything your own code decides from session.user. The worst case is a user id handed to a service-role client, an admin client that bypasses RLS.
Ask getClaims() instead and use claims.sub. Supabase RLS mistakes covers the policy side.
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
export async function GET() {
const supabase = await createClient()
const { data: { session } } = await supabase.auth.getSession()
if (!session) return new Response('Unauthorized', { status: 401 })
// session.user.id came from the cookie. Nothing verified it.
const admin = createAdminClient()
const { data } = await admin.from('invoices').select('*').eq('user_id', session.user.id)
return Response.json(data)
}import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
export async function GET() {
const supabase = await createClient()
const { data, error } = await supabase.auth.getClaims()
const userId = data?.claims.sub
if (error || !userId) return new Response('Unauthorized', { status: 401 })
const admin = createAdminClient()
const { data: invoices } = await admin.from('invoices').select('*').eq('user_id', userId)
return Response.json(invoices)
}How getClaims verifies the token, and what it can't catch
A Supabase access token is a JWT with a header, a payload and a signature. The signature proves the payload wasn't edited, and exp marks when the token should stop being trusted.
New projects sign tokens with asymmetric keys: a private key signs and a public key checks. With these, Supabase getClaims verifies locally against a cached copy of your project's public keys, so most calls skip the Auth server.
Supabase publishes the public keys as a JWKS (JSON Web Key Set, a public list of keys used to check signatures) at https://YOUR_PROJECT_REF.supabase.co/auth/v1/.well-known/jwks.json. If you can't use the client library, verify a Supabase JWT against that URL with a library like jose. Pin the issuer (https://YOUR_PROJECT_REF.supabase.co/auth/v1) and the audience (authenticated).
If your project still uses an HS256 shared secret (one secret string that both signs and checks tokens), getClaims() calls the Auth server to validate. Supabase strongly recommends against verifying with that secret yourself. Whoever holds it can impersonate your users, and it leaks easily through a NEXT_PUBLIC_ variable (see public env vars).
With asymmetric keys, getClaims() checks only signature and expiry. The token of a revoked session stays valid until it expires. Supabase says the only way to detect an ended session on the server is getUser().
Caching and ISR can hand one user's session to another
When @supabase/ssr refreshes a session on the server, the new token goes out in a Set-Cookie header. If ISR (incremental static regeneration, where Next.js saves a rendered page and reuses it) or a CDN (a network that serves saved copies of pages) caches that response for someone else, their browser stores your token and they are signed in as you.
- Don't use ISR on routes that handle auth or can trigger a refresh. Add
export const dynamic = 'force-dynamic'to pages that need a signed-in user. - From
@supabase/ssrv0.10.0, the library passesCache-Control,ExpiresandPragmato yoursetAllcallback on refresh. Apply them to the response. On older versions, setCache-Control: private, no-storeyourself. - Create the Supabase client inside the request handler. On Vercel's Fluid compute, a module-level client can be reused across requests from different users.
How to switch from getSession to getClaims, step by step
Find every server use of getSession
Search your repo for
getSession(. Note which hits run on the server and which run in client components.Swap identity checks to getClaims
Read
data?.claims.sub. On an error or a missing id, return 401 from route handlers and server actions, and redirect to login from Server Components and the proxy. Every server action is a public endpoint, so do this there too.Keep getSession for tokens only
If you forward the access token to another service,
getSession()is fine for that.Add getUser where revocation matters
Use it before account deletion, email changes, payments and admin actions:
const { data: { user }, error } = await supabase.auth.getUser() if (error || !user) return new Response('Unauthorized', { status: 401 })Keep sessions out of caches
Add
force-dynamicto authenticated pages, sendprivate, no-storewhere needed, and build the client inside each request.Check your signing keys
In the Supabase dashboard, see whether your project uses asymmetric keys or an HS256 shared secret. If it's the shared secret, keep it out of frontend code.
Test it yourself
Run these on your own app only, ideally against a dev or staging project.
- Search for risky calls:
grep -rn "getSession(" --include="*.ts" --include="*.tsx" --exclude-dir=node_modules --exclude-dir=.next .Bad: hits in route handlers, server actions, Server Components or the proxy that feed an access decision. Good: hits only in client components or code that forwards the token.
- Edit the cookie. Sign in, then open DevTools, Application, Cookies. Find the cookie named like
sb-YOUR_PROJECT_REF-auth-token. Change the user id or email inside the session JSON and reload a server-rendered page. - Bad: the page shows the edited identity.
- Good: it shows your real account or signs you out.
- Check the revoked session gap. Sign in as the same user in two browsers, then call
signOut()in browser A. In browser B, hit one route that usesgetClaims()and one that usesgetUser(). On asymmetric-key projects thegetClaims()route may keep answering until the access token expires. ThegetUser()route should return 401.
- Check caching. In DevTools, Network, click the document request of an authenticated page. Bad: a public or
s-maxageCache-Control on a response that sets a cookie. Good:private, no-storeon every response that sets a cookie.
Fix prompt you can paste
Paste this into Cursor or Claude Code at the repo root.
Audit this Next.js + Supabase repo for server-side auth checks that trust the session cookie.
1. Find every call to supabase.auth.getSession() in route handlers, server actions ('use server'), Server Components, lib/ helpers and proxy.ts or middleware.ts. List each as file:line and say what its result decides.
2. Replace identity checks with: const { data, error } = await supabase.auth.getClaims(); use data?.claims.sub as the user id. On error or no sub: return 401 in route handlers and server actions, redirect('/login') in Server Components, and in the proxy redirect while copying cookies from supabaseResponse. Never read session.user to authorize anything. Keep getSession() only where the raw access token is forwarded.
3. In sensitive actions (delete account, change email, payments, admin), use supabase.auth.getUser() so revoked sessions are rejected.
4. Find auth routes that use ISR or could be cached. Add export const dynamic = 'force-dynamic' and make sure Cache-Control is private, no-store when a session refreshes.
5. Find every service-role client that receives a user id and list where that id came from. Make sure the Supabase client is created inside each request handler, not at module level.
6. Search for any JWT secret in NEXT_PUBLIC_ variables, client code, or manual jwt.verify calls with a shared secret. Report them, do not print the values.
Output every finding as file:line, tagged vulnerable, fixed or needs review, and explain each change in one or two sentences. Finish by searching again for getSession( and listing any remaining server-side hits. Do not change RLS policies or unrelated code.Further reading
- Supabase docs: Creating a Supabase client for SSR (choosing an auth method)
- Supabase docs: Advanced SSR guide (caching, ISR, revoked sessions)
- Supabase docs: JSON Web Tokens (JWKS and signing keys)
FAQ
Is supabase.auth.getSession() safe to use on the server?
No, not for deciding who the user is. It reads the session from the cookie without revalidating it, and anyone can forge the session cookie. Use getClaims() or getUser() for identity, and keep getSession() for cases where you need the raw tokens.
What is the difference between getClaims and getUser in Supabase?
getClaims() verifies the token's signature and returns the claims inside it. On asymmetric keys it checks locally against cached public keys, so most calls skip the Auth server. getUser() always calls the Auth server and returns the latest user record, so it also notices a revoked session.
Does getClaims notice that a user signed out on another device?
Not on projects with asymmetric signing keys. getClaims() checks signature and expiry, and an unexpired token stays valid after the session behind it was revoked. Call getUser() where that gap matters.
How do I verify a Supabase JWT on my own server?
If you use asymmetric signing keys, verify against your project's JWKS at /auth/v1/.well-known/jwks.json with a library like jose. If you use an HS256 shared secret, Supabase recommends sending the token to the Auth server at /auth/v1/user instead of checking it yourself.
Can ISR break Supabase auth?
Yes. If a cached response includes a Set-Cookie header with a refreshed token, the next visitor can be signed in as the wrong person. Don't use ISR on authenticated routes and add force-dynamic to pages that need a signed-in user.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms