Two methods, almost the same name, both return a user object, both work in development. One of them is a security check and the other is a convenience reader, and using the convenience one on the server is a vulnerability that no test will catch because your own browser never forges a cookie.
If you have seen this warning in your terminal, this is what it is about:
Using the user object as returned from supabase.auth.getSession() could be
insecure! This value comes directly from the storage medium (usually cookies)
and may not be authentic. Use supabase.auth.getUser() instead.
What each one does
getSession() reads the auth cookie and decodes it. No network call, no
signature verification against the auth server, no revocation check. It returns
whatever is in the cookie, shaped like a session.
getUser() takes the access token and asks the auth server whether it is
genuine and still valid. It costs a round trip (deduplicated per request in
this repo by React cache) and its answer is authoritative.
Why "it's a signed JWT" is not the whole answer
The tokens are signed, and a client-side library that verifies the signature
locally can be legitimate. The problem is what getSession() specifically does:
it does not verify anything. It hands you the decoded contents of a value that
lives in someone else's browser.
Anyone can write a cookie. Set one containing a JSON blob shaped like a session
with sub set to another user's id, hit a server route that trusts
getSession(), and you are that user for the purposes of that route. There is
no exploit chain here: it is a devtools tab and a page refresh.
getUser() closes that because the auth server checks the token's signature,
expiry and revocation against its own state.
Where the confusion comes from
getSession() is correct in the browser. There, the session was put in storage
by the Supabase client itself after a successful sign-in: the client is not
trying to defend against the user of that browser, it is just remembering who
signed in. Reading it to decide whether to show a "Sign out" link is fine.
Server-side, the same call is reading a value an untrusted party sent you. Same method name, completely different trust context. That is the trap.
The rule
// server: pages, layouts, route handlers, server actions, the proxy
const user = await getUser(); // verified, raw Supabase user
const user = await getSessionUser(); // verified, the shared SessionUser shape
const user = await requireUser(); // verified, or redirected
// client: rendering decisions only
const { data: { session } } = await supabase.auth.getSession();
In this repo, server code never calls the Supabase client's auth methods
directly. It calls the helpers in src/lib/auth/session.ts, which wrap the
verified path and cache it per request. One import to audit, one place to fix.
The one server-side getSession that is safe
src/lib/auth/session.ts exports a getSession() of its own, because the
shared @/lib/auth/session surface (the one billing, the admin panel and the
support widget compile against) includes it. It is not the Supabase method
under a friendlier name:
export const getSession = cache(async () => {
const user = await getUser(); // the Auth server vouches for the token
if (!user) return null;
const supabase = await requestSupabase(); // the same client getUser() used
const { data } = await supabase.auth.getSession();
return data.session;
});
The verification happens first, and the decoded session is only handed back
once it has passed. Reach for it when you need the access token itself: to
forward it to another Supabase-aware service, say. For "who is calling", use
getSessionUser(); it is the same verified path with none of the temptation.
What about the cost?
A round trip per request sounds expensive until you look at what it replaces:
usually one or more database queries that were going to happen anyway, on the
same network. React cache collapses every call within one render into a single
request, so a layout, three components and an action share one verification.
If you genuinely need to avoid it (a hot public endpoint that only needs to
know whether someone is signed in) the honest optimisation is to verify the
JWT signature locally with the project's JWKS, not to trust an unverified
decode. That is a real technique with real key-rotation requirements. It is not
"call getSession() instead".
Roles have the same shape of trap
user.user_metadata.role // writable by the signed-in user. Never trust it.
user.app_metadata.role // writable only with the service-role key. Trust it.
updateUser lets a signed-in user write their own user_metadata. A role read
from there is a self-service promotion. This repo reads roles from
app_metadata through roleOf(), and the migration seeds it with the
service-role key.
And the layer below
Even a correct getUser() is a check in your application code. The row-level
security policies are the check in the database, and they are the one that saves
you when a query is missing its .eq("user_id", user.id).
Write both. The policy is the boundary; the TypeScript check decides what to render and gives a better error.
Checking your work
grep -rn "auth.getSession()" src/returns hits only in client components and in the guarded wrapper insrc/lib/auth/session.ts.- No server file reads
user_metadatafor anything that grants access. - The Supabase warning does not appear in your dev server output.
- With devtools, edit the auth cookie to a plausible but invalid value and load a protected page: you are redirected to sign-in, not shown someone's data.