getSession vs getUser: which is safe on the server?
getSession() is not safe for server-side authorization. It loads the session straight from storage — on the server, that's the request cookies — and decodes it without verifying the JWT signature, so a forged cookie passes. Use getUser() (revalidates with the Auth server) or getClaims() (verifies the signature against your project's JWKS) for any decision about who someone is or what they can see.
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 in the console complains. The failure mode only shows up when someone deliberately hands you a cookie you never issued.
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 that you should "prefer this method over 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.
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, 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.
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="*.tsx" app/ middleware.ts
# 2. Does your middleware ever revalidate?
grep -n "getUser\|getClaims" middleware.ts
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 that calls getSession() and never calls getUser() or getClaims() — and stays quiet the moment the same file revalidates.
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().
Catch this before it ships — free
GuardLayer scans every push for this and 24 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.