Supabase getClaims() vs getSession() in server code: the silent auth bug
nextjs-supabase

Supabase getClaims() vs getSession() in server code: 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.

2026-06-27
7 min read
Supabase getClaims() vs getSession() in server code: the silent auth bug

I found this bug during a security audit of a client app. The dashboard was "protected" — a check at the top of every Server Component:

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

The problem: a user could tamper with the cookie, swap in an expired token, and the session check still passed. The page loaded. The data was there.

getSession() reads from cookie storage. It does not verify the token is still valid.

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 isn't guaranteed to revalidate the Auth token."

The reason: getSession() loads the session object (access token, refresh token, expiry) directly from local storage — which in Next.js SSR means from the cookie. It returns whatever is in the cookie without checking signatures or expiry against the Auth server.

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: claims, error } = await supabase.auth.getClaims()

Validates the JWT signature locally using WebCrypto API against the project's cached JWKS (public keys). Returns the decoded JWT claims without a network call. The Supabase docs: "It's safe to trust getClaims() because it validates the JWT signature against the project's published public keys every time."

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. Returns the most current user object, including any metadata changes since the token was issued. Slower than getClaims() 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 cookie that the server cannot refresh — and that you cannot trust without signature validation.

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() — validates JWT locally, no network call
  const { data: claims } = await supabase.auth.getClaims()
 
  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: claims } = await supabase.auth.getClaims()
 
  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:

"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 but it trusts the JWT's expiry claim. 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 (typically 1 hour).

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.

Frequently Asked Questions

|

Have more questions? Contact us

Written by

Mahdi Br
Mahdi Br

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 — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.