AI disclosure: I wrote this article with AI assistance. I checked every claim and code sample against the Supabase and Next.js docs linked at the end. If you spot a mistake, tell me in the comments and I'll fix it.
If you let an AI agent write the auth code in a Supabase + Next.js app, check one thing first: what your server code trusts.
Agents (and plenty of older tutorials) still produce this in Server Components, Server Actions, and route handlers:
const { data: { session } } = await supabase.auth.getSession()
if (!session) redirect('/login')
const userId = session.user.id // used to decide what this request can read or write
It looks fine and it works in testing. The problem is where the data comes from.
Why getSession() is the wrong check on the server
On the server, getSession() loads the session from the auth cookie without revalidating it. A cookie is user input. The Supabase SSR guide says so directly: anyone can forge the session cookie, so never trust getSession() inside server code.
If session.user.id decides which rows a request can read or write, a forged cookie decides it too.
Use getClaims() to protect pages and data
getClaims() verifies the access token's signature before it returns the claims:
- On projects with asymmetric JWT signing keys (ECC or RSA, the default for new projects), it verifies against your project's JWKS endpoint (
/.well-known/jwks.json). The keys are usually cached, so this is fast. - On projects still using a symmetric key, it sends a request to the Auth server to verify the token, similar to
getUser().
A protected Server Component:
// app/dashboard/page.tsx
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
export default async function DashboardPage() {
const supabase = await createClient()
const { data, error } = await supabase.auth.getClaims()
if (error || !data?.claims) redirect('/login')
const userId = data.claims.sub // verified subject (the user's id)
const { data: projects } = await supabase
.from('projects')
.select('id, name')
.eq('owner_id', userId) // keep RLS on as well; this filter is not your only lock
return <ProjectList projects={projects ?? []} />
}
Which call to use when
| You need | Use |
|---|---|
| To protect a page, Server Action, route handler, or query | getClaims() |
| An up-to-date user record from the Auth server |
getUser() (makes a network call) |
| The raw access or refresh token, e.g. to forward to another service |
getSession(), never for authorization
|
Server Actions are public endpoints
A Server Action can be called over HTTP even if no button in your UI calls it. Treat each one like an API route: validate input, verify claims, then write.
Free rules pack (optional)
If you want the agent to stop inventing the old pattern, the free Next.js + Supabase Loadout pack includes this as a pinned rule: https://loadout-blush.vercel.app/packs/nextjs-supabase
Canonical fix page: https://loadout-blush.vercel.app/fixes/supabase-ssr-getsession-vs-getclaims
Docs
- Supabase SSR / auth cookie guidance (getSession vs verified claims)
- Next.js App Router Server Components / Server Actions
Top comments (0)