Supabase

Supabase getClaims() vs getSession(): The Silent Auth Bug

Your auth looks correct but users can bypass it. The bug: getSession() in server code reads from local storage without revalidating the token against the Auth server.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
7 min read

A common way to "protect" a dashboard is a check at the top of every Server Component:

js
const { data: { session } } = await supabase.auth.getSession()
if (!session) redirect('/login')

The problem: the session is whatever the request's cookie says. A forged cookie, holding a token whose signature was never checked and an expires_at edited into the future, passes this check. The page loads. The data is there.

getSession() reads from cookie storage. It does not verify the token's signature.

What the Supabase docs actually say#

The Supabase SSR auth guide is explicit:

"Never trust supabase.auth.getSession() inside server code such as Proxy. It reads the session out of the cookie without revalidating it."

The reason: getSession() loads the session object (access token, refresh token, expiry) directly from the storage medium — which in Next.js SSR means the cookie. It returns whatever is in the cookie without checking the token's signature. The one thing it does do is refresh: per the auth-js source, "if the session's access token is expired or is about to expire, this method will use the refresh token to refresh the session" — but the expiry it tests is the expires_at stored in that same cookie.

This is fine on the client where the session is managed by the SDK. It is a security hole on the server where you are making trust decisions.

The three auth functions and when to use each#

getClaims() — for auth checks in server code#

js
const { data, error } = await supabase.auth.getClaims()
const claims = data?.claims // data is { claims, header, signature }

Verifies the JWT, checks its exp, and returns the decoded claims. The Supabase Next.js guide puts it this way: "getClaims() verifies the token's signature on every call." Where that verification happens depends on your project's JWT signing keys:

  • Asymmetric keys (RSA or ECC — the default for new projects): verified locally with the WebCrypto API against the project's JWKS (/auth/v1/.well-known/jwks.json), usually with no network request. The JWKS itself is fetched when it is not cached — Supabase's edge caches it for 10 minutes and the client library may keep it in memory for another 10 — so a cold serverless or edge invocation still makes that one request.
  • Legacy symmetric secret (HS256): there is no public key to check against, so it calls the Auth server. The getClaims() reference is explicit: "If the project is not using an asymmetric JWT signing key (like ECC or RSA) it always sends a request to the Auth server (similar to GoTrueClient.getUser) to verify the JWT." The same fallback is used when WebCrypto is unavailable in the runtime.

If your project still signs with the legacy JWT secret, getClaims() is as safe as getUser() but not faster; migrating to asymmetric signing keys is what makes it local.

Use this in:

  • proxy.ts / proxy.js
  • Server Components
  • Server Actions ("use server")
  • Route Handlers

getUser() — when you need the freshest user record#

js
const { data: { user }, error } = await supabase.auth.getUser()

Makes a network call to the Auth server on every call. Returns the most current user object, including any metadata changes since the token was issued. Slower than getClaims() on asymmetric-key projects, but authoritative.

Use this when you need to show or act on user metadata that might have changed after login (role changes, email verification status).

getSession() — only safe on the client#

js
// ✅ Fine in a Client Component
const { data: { session } } = await supabase.auth.getSession()

On the client, the Supabase SDK manages session refresh automatically. The session in local storage is kept current. The risk of stale data is low.

On the server, the session comes from a request cookie that the client controls — and that you cannot trust without signature validation, whether or not getSession() refreshed it.

The correct pattern for server-side auth#

js
import { createServerClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
export async function proxy(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))
        },
      },
    }
  )
 
  // ✅ getClaims() — verifies the JWT (locally with asymmetric keys,
  // via the Auth server with the legacy symmetric secret)
  const { data } = await supabase.auth.getClaims()
  const claims = data?.claims
 
  if (!claims && !request.nextUrl.pathname.startsWith('/login')) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }
 
  return supabaseResponse
}
js
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
 
export default async function Dashboard() {
  const supabase = await createClient()
 
  // ✅ getClaims() in a Server Component
  const { data } = await supabase.auth.getClaims()
  const claims = data?.claims
 
  if (!claims) {
    redirect('/login')
  }
 
  const userId = claims.sub
 
  const { data: profile } = await supabase
    .from('profiles')
    .select('*')
    .eq('id', userId)
    .single()
 
  return <div>{profile.name}</div>
}

The CDN / ISR cache leak#

This is a separate but related danger. The Supabase SSR docs warn:

"If your app uses ISR (Incremental Static Regeneration) or is deployed behind a CDN, caching of HTTP responses can cause users to receive another user's session."

When Next.js ISR or a CDN caches a response that includes Set-Cookie headers (the Supabase session cookie), a subsequent user requesting the same cached response receives those headers — effectively signed in as the previous user.

The fix: never cache authenticated responses at the CDN layer. Add cache control headers to any response that touches auth:

js
supabaseResponse.headers.set('Cache-Control', 'private, no-store')

Or set it in next.config.mjs for all authenticated routes:

js
async headers() {
  return [
    {
      source: '/dashboard/:path*',
      headers: [{ key: 'Cache-Control', value: 'private, no-store' }],
    },
  ]
}

When getClaims() is not enough#

getClaims() validates the JWT signature and its expiry claim, and nothing more. If you have revoked a user's session on the Supabase side (admin dashboard, logout from all devices), the JWT may still be valid until it expires (the default access-token lifetime is 1 hour). The advanced SSR guide says so directly: "an unexpired token stays valid even when the session behind it was revoked."

For critical operations — changing passwords, processing payments, deleting accounts — use getUser() which makes a live check against the Auth server:

js
'use server'
 
import { createClient } from '@/lib/supabase/server'
 
export async function deleteAccount() {
  const supabase = await createClient()
 
  // For destructive operations: use getUser() for a live auth check
  const { data: { user }, error } = await supabase.auth.getUser()
 
  if (error || !user) {
    throw new Error('Unauthorized')
  }
 
  await supabase.from('profiles').delete().eq('id', user.id)
}

Checklist#

  • Replace all getSession() calls in server code with getClaims().
  • Replace getSession() in Server Actions with getClaims().
  • For critical mutations: use getUser() instead of getClaims().
  • Ensure authenticated page responses include Cache-Control: private, no-store.
  • Do not cache any response that includes a Set-Cookie auth header at the CDN layer.
  • Verify: an expired or tampered cookie causes a redirect to /login, not a successful page load.

Further reading: Supabase Email Confirmation Not Sending: 5 Fixes (2026), Supabase Auth + Middleware: Session Guide for Next.js 15. Also see Fix Supabase: Invalid Refresh Token Already Used.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.