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.
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.
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
Next.js proxy.js matcher not working: static assets and auth gaps
Without a matcher, proxy.js runs on every request including _next/static and public assets. And Server Functions are not separate routes — a matcher gap silently drops auth coverage.
Supabase redirectTo not working on Vercel previews: SITE_URL fix
The redirect lands on your production domain instead of the preview URL, or you get a "redirect URL not allowed" error. Here is the exact Supabase dashboard config and the Vercel env var pattern.
Missing Suspense boundary with useSearchParams in Next.js: 3 fixes
The error only surfaces during the production build — dev mode hides it completely. Here is why, and the three fixes ranked by situation.
Browse by Topic
Find stories that matter to you.
