LEAKCADE

Supabase getSession vs getUser: stop trusting the cookie on your server

Updated 7 min read

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.

Supabase's docs warn against using the user object from getSession() for authorization decisions.

Which Supabase auth call do I use?Decision flow for server code that needs the user. getSession().user is forgeable, raw tokens use getSession, fresh data or a revoked check uses getUser, everything else uses getClaims.STARTServer code needs the userYESASKAllow access from getSession().user?FAKEAnyone can edit that cookieNOYESASKNeed raw tokens to forward them?RAWgetSession, tokens onlyNOYESASKNeed fresh data or a revoked check?FRESHgetUser asks the Auth serverNOYESCLAIMSgetClaims() verifies the JWTSTARTServer code needs the userYESASKAllow access from getSession().user?NOFAKEAnyone can edit that cookieYESASKNeed raw tokens to forward them?NORAWgetSession, tokens onlyYESASKNeed fresh data or a revoked check?NOFRESHgetUser asks the Auth serverYESCLAIMSgetClaims() verifies the JWT
Default to getClaims() for page and data checks.
getSession()getClaims()getUser()
What it doesReads the cookieVerifies the JWTAsks the Auth server
Safe to authorize withNoYesYes
Network callNoOnly for keys or refresh (always on HS256)Every call
Sees a revoked sessionNoNot until expiry (asymmetric keys)Yes
Best forForwarding tokensProtecting pages and dataFresh user record

Based on Supabase's SSR and JWT docs. Check the current docs for your version.

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:

  1. The attacker edits the user id inside their own cookie.
  2. Your server reads it with getSession() and believes it.
  3. 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.

BEFORETrusts the cookie
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)
}
AFTERVerifies the token
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)
}
The id now comes from a token whose signature was checked.

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.

How to switch from getSession to getClaims, step by step

  1. Find every server use of getSession

    Search your repo for getSession(. Note which hits run on the server and which run in client components.

  2. 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.

  3. Keep getSession for tokens only

    If you forward the access token to another service, getSession() is fine for that.

  4. 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 })
  5. Keep sessions out of caches

    Add force-dynamic to authenticated pages, send private, no-store where needed, and build the client inside each request.

  6. 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.

  1. 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.

  1. 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.
  2. Bad: the page shows the edited identity.
  3. Good: it shows your real account or signs you out.
  1. 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 uses getClaims() and one that uses getUser(). On asymmetric-key projects the getClaims() route may keep answering until the access token expires. The getUser() route should return 401.
  1. Check caching. In DevTools, Network, click the document request of an authenticated page. Bad: a public or s-maxage Cache-Control on a response that sets a cookie. Good: private, no-store on every response that sets a cookie.

Fix prompt you can paste

Paste this into Cursor or Claude Code at the repo root.

FIX PROMPT
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

  1. Supabase docs: Creating a Supabase client for SSR (choosing an auth method)
  2. Supabase docs: Advanced SSR guide (caching, ISR, revoked sessions)
  3. 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 repo

Leakcade 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

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.