Skip to content

getSession() vs getUser(): the Supabase trust trap

getSession() decodes a cookie the browser controls; getUser() verifies it with the auth server. On the server, only one of them is a security check.

Supabase Auth4 min readships at docs/solutions/supabase-auth/getsession-vs-getuser.md

Tags: supabase · auth · security · sessions · nextjs · jwt

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 in src/lib/auth/session.ts.
  • No server file reads user_metadata for 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.