# 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.

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

```ts
// 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:

```ts
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

```ts
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
