Supabase · security ·
Supabase getSession vs getUser vs getClaims: which to use where
Supabase Auth gives you three ways to ask "who is this?" on the server: getSession(), getUser() and getClaims(). They look interchangeable, and in a quick test they all return the same user. They aren't interchangeable. One of them reads whatever the cookie says, one asks the Auth server, and one checks a signature.
This guide pairs with Ship-Ready AI Rules, and if you want the next step, read Cursor usage limits: the weekly pool, legacy plans and when they reset.
This post explains what each method actually does, which to use in a Next.js App Router app, and why the choice matters for security. Supabase behaviour comes from the Supabase docs and the @supabase/auth-js source (supabase-js 2.118.0, @supabase/ssr 0.12.7), checked on 17 October 2026. The code samples type-check with tsc --strict against Next.js 16.4. This site isn't affiliated with Supabase.
The short answer
- Protecting a page or reading user data:
getClaims(). - Before a write, or when you need the current user record:
getUser(). - When you need the raw access or refresh token:
getSession(), but never use its user object to decide who someone is on the server.
Supabase's own summary, from its Next.js guide: "use getClaims to verify identity (typically for protecting pages and data), getUser when you need an up-to-date user record from the Auth server, and getSession when you need the access or refresh token directly, but don't rely on the user object it returns for authorization decisions."
getSession(): reads the cookie, verifies nothing
getSession() returns the session from the client's storage. In the browser, that's local storage or cookies. On the server, with @supabase/ssr, it's the request cookies. The getSession reference says it plainly: it "loads values directly from the storage attached to the client. If that storage is based on request cookies for example, the values in it may not be authentic."
Cookies come from the browser, and anyone can edit their own cookies. So this is unsafe in server code:
// Don't do this: the session comes straight from the cookie.
export async function getUserIdUnsafe() {
const supabase = await createClient();
const { data } = await supabase.auth.getSession();
return data.session?.user.id;
}
The Next.js guide puts it bluntly: "Anyone can forge the session cookie, so trusting it without verification lets an attacker render another user's page." The library warns you too. If you read the user object from a server-side session, auth-js logs a warning that it "may not be authentic" and suggests getUser() instead.
getSession() still has a use: when you need the access token itself, for example to pass to another API. Just don't treat its contents as proof of identity.
getUser(): asks the Auth server every time
getUser() sends the access token to your project's Auth server and gets the user record back. The getUser reference says "the returned value is authentic and can be used to base authorization rules on."
Because the server checks the token on every call, getUser() can notice things a token alone can't tell you. Supabase's docs describe this as an authentic check, so if the user signed out, or you banned or deleted them, the call should fail even if the access token hasn't expired yet. We haven't tested every one of those cases. It also returns the latest user record, such as an email change or updated metadata.
The cost is a network round trip on every call. That's fine before a write. It's wasteful on every page render.
getClaims(): verifies the token's signature
getClaims() gets the access token (from the session, refreshing it if it's close to expiring), then verifies the JWT's signature before returning its claims. How it verifies depends on your project's signing keys.
Asymmetric signing keys (ECC or RSA): the default for new projects, per the Next.js guide. getClaims() fetches your project's public keys from:
/auth/v1/.well-known/jwks.json
It then verifies the signature locally with the WebCrypto API. The getClaims reference notes the JWKS endpoint "is often cached, resulting in significantly faster responses". In the auth-js source, the in-memory key cache lasts 10 minutes (JWKS_TTL). The signing keys guide adds that Supabase's edge also caches the endpoint for 10 minutes.
Symmetric (legacy JWT secret) projects: your server can't verify an HS256 token without the secret. So getClaims() falls back to calling getUser(), the same Auth server request. The source also falls back when the token has no key id or WebCrypto isn't available.
We checked this behaviour against auth-js 2.118.0 with a test key pair and a stubbed fetch:
valid ES256 token -> claims returned; 1 request (jwks.json)
tampered payload -> "Invalid JWT signature"; 0 requests
HS256 token -> falls back to GET /auth/v1/user
expired token -> "JWT has expired"; 0 requests
A forged or edited token fails locally, without any network call, once the keys are cached.
The trade-off: a valid signature only proves Supabase issued the token. It doesn't prove the session is still active. If a user signs out or is banned, an access token they already hold keeps passing getClaims() until it expires. That's why the guidance is getClaims() for reads and pages, and getUser() where you want the Auth server's answer.
Which one where in a Next.js App Router app
| Where | Use |
|---|---|
| Proxy / middleware | getClaims() |
| Server Component (page) | getClaims() |
| Server Action that writes | getUser() |
| Route Handler that writes | getUser() |
| Need the raw token | getSession() (token only) |
| Browser UI state | either; RLS protects data |
Whichever method you use, Row Level Security is still what protects the data. A missing auth check in a Server Action is a bug, but an owner-scoped RLS policy stops it from leaking other users' rows.
Proxy (middleware): refresh the session with getClaims
Server Components can't write cookies, so something has to refresh expired tokens and store the new ones. In current Next.js that's proxy.ts. Next.js 16 renamed middleware.ts to proxy.ts; on Next.js 15 and earlier, keep middleware.ts and name the function middleware. The Supabase code is the same either way. The Next.js guide says the proxy refreshes the token by calling getClaims().
The server client, in lib/supabase/server.ts:
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// Called from a Server Component: the proxy refreshes the session.
}
},
},
},
);
}
The session refresh, in lib/supabase/proxy.ts:
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_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),
);
},
},
},
);
// Don't run code between createServerClient and getClaims().
const { data } = await supabase.auth.getClaims();
const claims = data?.claims;
if (!claims && request.nextUrl.pathname.startsWith("/dashboard")) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
// Return supabaseResponse so the refreshed cookies reach the browser.
return supabaseResponse;
}
And proxy.ts at the project root:
import { type NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/proxy";
export async function proxy(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
Two details from the guide that cause sign-out bugs:
- Return the right response. Return the
supabaseResponsethatsetAlllast built. An earlier response doesn't carry the refreshed cookies. If you need a different response, copy the cookies (and the cache headers) onto it first. - Refresh in one place. Let the proxy do the refresh, so Server Components don't each try to refresh the same token.
The redirect in the proxy is a convenience, not your only check. Check again in the page or action itself.
Pages: getClaims in Server Components
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;
const { data: projects } = await supabase
.from("projects")
.select("id, name")
.eq("user_id", userId);
return <pre>{JSON.stringify(projects, null, 2)}</pre>;
}
claims.sub is the user's id. The query still runs through RLS with the user's token, so the .eq filter is a convenience, not the security boundary.
Writes: getUser in Server Actions and Route Handlers
"use server";
import { createClient } from "@/lib/supabase/server";
export async function deleteProject(projectId: string) {
const supabase = await createClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) throw new Error("Not signed in");
const { error: dbError } = await supabase
.from("projects")
.delete()
.eq("id", projectId)
.eq("user_id", user.id);
if (dbError) throw dbError;
}
One round trip per write buys you the Auth server's answer: a signed-out or banned user should be turned away even if their access token hasn't expired yet. If latency matters more than that window for a particular action, getClaims() is defensible there too. Make it a deliberate choice, not an accident.
Signing keys: check which kind your project uses
Whether getClaims() is fast depends on your signing keys. In the dashboard, check your project's JWT signing keys (see the signing keys guide). If the project still uses the legacy JWT secret, getClaims() works but makes the same Auth server call as getUser(). Migrating to an asymmetric key makes it local.
Per the guide, asymmetric keys also make rotation and revocation easier: public keys are published at the JWKS endpoint, so you don't have to redeploy a shared secret everywhere it's used. Because of the caching described above, plan rotations with the guide's timing in mind rather than expecting instant effect.
A quick checklist
- Search your server code for
getSession(. Keep it only where you need the raw token. - Proxy refreshes the session with
getClaims()and returnssupabaseResponse. - Pages and Server Components check
getClaims()and redirect when there are no claims. - Server Actions and Route Handlers that write call
getUser(). - RLS policies scope every user table to
(select auth.uid()). - Check whether your project uses asymmetric signing keys.
Related
- Supabase RLS policies that pass review but still leak data: the database half of the same problem.
- Supabase service role key exposed: why bypassing RLS isn't a fix.
- Our free checker (MIT, runs locally) flags
getSession()trusted on the server (its A1 check) along with leaked keys, RLS gaps and webhook issues. It's heuristic, so treat it as a first pass.
Disclosure: this is our own paid product. AI Rules ($19 plus VAT where applicable) includes a Supabase server-auth skill that tells your coding agent to use getClaims() for pages, getUser() for writes, and never getSession() for authorisation on the server. Its security-review and ship-check skills check for the same thing. It doesn't replace code review.
Sources (checked 17 October 2026):