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.
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:
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#
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#
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#
// ✅ 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#
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
}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:
supabaseResponse.headers.set('Cache-Control', 'private, no-store')Or set it in next.config.mjs for all authenticated routes:
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:
'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 withgetClaims(). - Replace
getSession()in Server Actions withgetClaims(). - For critical mutations: use
getUser()instead ofgetClaims(). - Ensure authenticated page responses include
Cache-Control: private, no-store. - Do not cache any response that includes a
Set-Cookieauth header at the CDN layer. - Verify: an expired or tampered cookie causes a redirect to
/login, not a successful page load.
Related#
- Supabase auth complete session and middleware guide — the full wiring of Supabase SSR with Next.js, including the cookies configuration.
- Handle Supabase auth errors in Next.js middleware — error handling patterns for the auth layer.
- Supabase auth production: 11 lessons — broader production auth lessons from running Supabase in production.
- Supabase RLS silent failures — the companion bug: your auth passes but your data queries return nothing.
- Next.js proxy.js matcher not working: static assets and auth gaps
- Is It Safe to Expose Firebase API Key to the Public?
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Continue Reading
Supabase Auth Session Disappears on Refresh? Fix (2026)
Supabase auth sessions mysteriously disappearing after page refresh? Learn the exact cause and fix it in 5 minutes with this tested solution.
Supabase Email Confirmation Not Sending: 5 Fixes (2026)
Email confirmations not sending from Supabase? Learn the exact causes and fixes for SMTP, template, and configuration issues in 10 minutes.
Clerk vs Better Auth vs Supabase: Next.js Auth in 2026
We tested all four major auth solutions across 50+ real-world scenarios in production. Here is the honest comparison nobody else gives you — including the middleware vulnerability that changed everything, migration costs, and which one actually scales.
Browse by Topic
Find stories that matter to you.
