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: 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. The Supabase SSR auth guide is exp
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')
Enter fullscreen mode Exit fullscreen mode
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()
Enter fullscreen mode Exit fullscreen mode
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()
Enter fullscreen mode Exit fullscreen mode
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()
Enter fullscreen mode Exit fullscreen mode
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 filename="proxy.ts"
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 filename="app/dashboard/page.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>
}
Enter fullscreen mode Exit fullscreen mode
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 filename="proxy.ts"
supabaseResponse.headers.set('Cache-Control', 'private, no-store')
Or set it in `next.config.mjs` for all authenticated routes:
```js filename="next.config.mjs"
async headers() {
return [
{
source: '/dashboard/:path*',
headers: [{ key: 'Cache-Control', value: 'private, no-store' }],
},
]
}
Enter fullscreen mode Exit fullscreen mode
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 filename="app/account/delete/actions.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.
## Related
- [Supabase auth complete session and middleware guide](https://www.iloveblogs.blog/guides/supabase-auth-complete-session-middleware-guide) — the full wiring of Supabase SSR with Next.js, including the cookies configuration.
- [Handle Supabase auth errors in Next.js middleware](https://www.iloveblogs.blog/post/handle-supabase-auth-errors-middleware) — error handling patterns for the auth layer.
- [Supabase auth production: 11 lessons](https://www.iloveblogs.blog/post/supabase-auth-production-year-11-lessons) — broader production auth lessons from running Supabase in production.
- [Supabase RLS silent failures](https://www.iloveblogs.blog/post/supabase-rls-silent-failures-debug) — the companion bug: your auth passes but your data queries return nothing.
---
*Originally published at [https://www.iloveblogs.blog](https://www.iloveblogs.blog/post/fix-supabase-getclaims-vs-getsession-server-auth)*
Enter fullscreen mode Exit fullscreen mode
