Supabase getSession vs getUser vs getClaims: which to use
On the server, use getClaims() or getUser(), not getSession(). getSession() loads the session straight from storage, which on the server means the request cookies, and never verifies the JWT signature, so a forged cookie passes. getClaims() verifies the token against your project's JWKS and is the fast default for gating pages and routes. getUser() asks the Auth server on every call: slower, but it catches a session that was signed out or revoked. Keep getSession() for client-side UI.
This is the single most common auth mistake in a Next.js + Supabase app, and it's easy to make because the insecure version works. You log in, getSession() returns a session, your middleware lets you through, and nothing breaks. The failure mode only shows up when someone deliberately hands you a cookie you never issued.
If you landed here from this server log line, it's the same problem:
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic.
supabase-js prints it the first time server code reads a field off the user that getSession() returned. The warning names getUser() as the fix; Supabase's current docs point at getClaims() too. Either one makes the check real, as shown below.
Is getSession() secure on the server?
No. getSession() reads whatever is in the client's storage and hands it back after a format and expiry check — it does not prove the token is authentic. Supabase's own reference documentation is blunt about it:
This method 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 and therefore it's strongly advised against using this method and its results in such circumstances.
In the browser, storage is the user's own localStorage — they can already see their own data, so a self-forged session buys them nothing at the database layer, where RLS policies still run against the real JWT. On the server, storage is the incoming request, which is entirely attacker-controlled. That's the difference, and it's the whole ballgame.
The three methods, and what each actually proves
| What it does | Verifies the JWT? | Network call | |
|---|---|---|---|
getSession() | Decodes the token in storage | No | None |
getUser() | Asks the Auth server to revalidate | Yes | Every call |
getClaims() | Verifies the signature against your JWKS | Yes | Only on legacy HS256 |
getUser() sends the access token to GoTrue (Supabase's Auth server) and gets back the authoritative user record. It's correct, and it catches revoked sessions, but it costs a round trip on every request.
getClaims() is the newer option and is what Supabase now points you at. It verifies the token's signature against your project's JWKS endpoint, which is cached — so with asymmetric signing keys (ECC/RSA) the check happens locally with no Auth-server call. On a legacy symmetric HS256 secret it falls back to asking the Auth server, exactly like getUser(). The docs are explicit: "Prefer this method over GoTrueClient.getUser which always sends a request to the Auth server for each JWT."
The practical rule: getClaims() for gating pages and routes, getUser() when you need revocation-accurate freshness, getSession() only on the client for non-security UI — showing an avatar, toggling a "Sign out" button.
The same rule rules out one more object: the data.user that signUp() hands back. It is not a session and it is not proof of anything — Supabase will return a real user row for an existing unconfirmed address whatever password you send.
What the insecure version looks like
Here's the pattern that ships constantly, usually because getSession() reads like the obvious way to ask "is someone logged in?":
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { getAll: () => request.cookies.getAll(), setAll: () => {} } }
);
// Reads the cookie and decodes it. Never verifies the signature.
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = { matcher: ["/dashboard/:path*"] };
- Warningmiddleware.ts:12
getSession() trusted in server code
In server code (middleware or proxy, route handlers, server actions) authorize with supabase.auth.getUser() — it revalidates the JWT — not getSession(). getSession() is fine on the client, where the session is already trusted.
How do I fix a middleware that trusts getSession()?
Swap the trust boundary to a method that verifies the token. One line changes:
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
);
},
},
}
);
// Verified: signature checked against your project's JWKS.
const { data } = await supabase.auth.getClaims();
if (!data?.claims) {
return NextResponse.redirect(new URL("/login", request.url));
}
// Return this response so refreshed cookies reach the browser.
return response;
}
Two details that are load-bearing and easy to lose:
- Put no code between
createServerClientand thegetClaims()/getUser()call. That call is also what refreshes an aging token; anything in between breaks the timing. - Return the response object the
setAllhandler rebuilt. Construct a freshNextResponseand drop its cookies and you'll get intermittent logouts — the session goes stale andgetUser()starts returning null.
On Next.js 16 this file is proxy.ts and the exported function is proxy: the middleware convention is deprecated and renamed. The Supabase code inside doesn't change. The middleware-to-proxy migration covers the codemod.
Middleware is a gate, not a wall
Fixing getSession() hardens your middleware. It doesn't make middleware sufficient on its own, and that distinction matters more than it sounds.
CVE-2025-29927 — a spoofable
x-middleware-subrequestheader let requests skip Next.js middleware entirely, bypassing any auth check living there. See the full breakdown of the middleware auth bypass.
An entire class of bug disappears if middleware is your first check rather than your only one. Re-verify in the Server Component, Route Handler, or Server Action that actually touches data, and keep RLS enabled so Postgres enforces ownership even when the app layer is wrong. That layered posture is the whole point of the Next.js + Supabase security checklist.
A 30-second self-check
# 1. Any server-side file deciding auth from getSession()?
grep -rn "auth.getSession" --include=middleware.ts --include=proxy.ts --include="*.tsx" app/ middleware.ts proxy.ts 2>/dev/null
# 2. Does your middleware (proxy.ts on Next.js 16) ever revalidate?
grep -n "getUser\|getClaims" middleware.ts proxy.ts 2>/dev/null
If the first command returns hits and the second returns nothing, your auth gate can be fooled by a cookie. GuardLayer's supabase/getsession-server-trust rule flags exactly that shape — a middleware.ts, or its Next.js 16 replacement proxy.ts, that calls getSession() and never calls getUser() or getClaims() — and stays quiet the moment the same file revalidates. It only looks at files with those two names, so a getSession() check in a Server Component or route handler is outside the rule.
FAQ
Is getSession() always insecure? No — it's fine on the client, where the session came from the user's own storage and RLS still guards the database. It's insecure specifically in server code, where the "storage" is an attacker-controllable request cookie.
Should I use getUser() or getClaims()?
getClaims() for most route and page gating: with asymmetric signing keys it verifies locally against a cached JWKS, so it's fast. Use getUser() when you need the authoritative user record or must catch a session revoked seconds ago.
Isn't getUser() on every request too slow?
That's the trade getClaims() exists to solve. Verify with getClaims() in middleware and reserve getUser() for the routes that genuinely need Auth-server freshness.
If RLS is enabled, does it matter which one I use? Yes. RLS protects your data, but the app layer decides what page renders and what a Server Action runs. A spoofed session can trigger actions and reveal UI even when the database refuses the rows.
Why does getSession() return a user when getUser() returns null?
Because getSession() never checked. A decoded-but-unverified or stale token still looks like a session to it. Trust getUser() or getClaims().
See which of your Supabase tables anyone can read, free
Connect your Supabase project read-only. GuardLayer checks RLS, policies, grants and functions as they are right now, and gives you the SQL to fix each problem. It never reads your rows and can't change anything.
Check my Supabase projectPrefer to check code instead? Scan your repo, no signup.