DEV Community

Anthony Pan
Anthony Pan

Posted on Originally published at loadout-blush.vercel.app AI-assisted

Supabase + Next.js 16: authorize with getClaims(), not getSession()

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
Enter fullscreen mode Exit fullscreen mode

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 ?? []} />
}
Enter fullscreen mode Exit fullscreen mode

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)