# Session cookies

> Read and write the @supabase/ssr cookie format from any framework.

Source: https://bettersupabase.com/docs/auth/sessions

The browser session lives in `sb-<project>-auth-token`, exactly as
`@supabase/ssr` writes it: `base64-` prefixed JSON, split into `.0`, `.1`, …
chunks when it is larger than 3180 characters. better-supabase uses the
chunker and encoders from `@supabase/ssr` itself, so both read each other's
cookies.

The primitives in `better-supabase/ssr` are framework-agnostic; adapters for
SvelteKit, Nuxt, TanStack Start and React Router build on them.

```ts
import {
  parseCookies,
  readSession,
  sessionCookieName,
  writeSession,
} from "better-supabase/ssr";

const name = sessionCookieName(env.url);
const cookies = parseCookies(request.headers.get("cookie"));
const session = readSession(cookies, name); // null when absent or corrupted

const writes = writeSession(cookies, name, nextSession); // or null to sign out
```

`writeSession` returns every cookie to set, including expiries for chunks
the new value no longer needs. Chunks left over from a partial write (a mix
of two generations) read as no session rather than as a broken one.

Whenever you send session cookies, send `AUTH_CACHE_HEADERS` too.
`resolution.apply(response)` does both.

## Encoding [#encoding]

`@supabase/ssr` 0.12 writes the cookie in one of two shapes, set with
`cookies.encode`:

| `encode`                    | The cookie holds                         | The user object lives in                                           |
| --------------------------- | ---------------------------------------- | ------------------------------------------------------------------ |
| `user-and-tokens` (default) | the access token, refresh token and user | the cookie                                                         |
| `tokens-only`               | the access token and refresh token       | `auth.userStorage` (localStorage in the browser, memory elsewhere) |

`tokens-only` keeps the cookie small, often under one chunk, and is the
recommended setting for new apps. `readSession` reads both shapes, so you can
switch at any time; a `tokens-only` session has no `user` field, and
`sessionEncoding(session)` tells you which shape a cookie has. Pass the same
value to `writeSession` and to the browser client:

```ts
const writes = writeSession(cookies, name, nextSession, cookieOptions, {
  encode: "tokens-only",
});
```

```ts title="src/lib/supabase/client.ts"
import { createClient } from "better-supabase/client";

export const bs = createClient(betterSupabase, {
  env: { url, publishableKey },
  cookies: { encode: "tokens-only" },
});
```

`createClient` also passes `auth.userStorage` through to `@supabase/ssr` when
you want the user object somewhere other than localStorage. Keep `encode` the
same on both sides: a server on `user-and-tokens` writes the user object back
into the cookie, and a browser client on `user-and-tokens` that reads a
`tokens-only` cookie throws on `session.user`.
[BS412](/docs/cli/doctor#bs412) reports a mismatch in your sources.

Nothing in better-supabase depends on the user object in the cookie.
`bs.session()`, `useSession()` and the browser client's `auth.current()` read
the user's id, email, role and metadata from the verified (on the server) or
decoded (in the browser) access token claims, so they are the same with either
encoding. That is also why the claims, not `getUser()`, are the check to use
on every request: they verify locally without a call to the Auth server. Call
`supabase.auth.getUser()` only in flows that must see a user who was deleted
or banned since the token was issued, such as changing a password or deleting
an account.

## Moving the cookie to another domain or path [#moving-the-cookie-to-another-domain-or-path]

When a deploy changes the cookie `domain` or `path`, the old cookies stay in
the browser at the old scope, where the new code never overwrites them.
`clearSessionAtScopes` returns `Max-Age=0` writes for every chunk of the
session cookie at each old scope, like `clearAuthCookiesAtScopes` from
`@supabase/ssr`:

```ts
import { clearSessionAtScopes, serializeCookie } from "better-supabase/ssr";

for (const write of clearSessionAtScopes(cookies, name, [
  { domain: ".old.example.com" },
  { path: "/app" },
])) {
  response.headers.append("Set-Cookie", serializeCookie(write));
}
```

List only the scopes earlier deploys used: a scope equal to the current one
clears the live session. Browsers ignore writes for a domain the host doesn't
own, so listing extra domains is safe. Append these writes as raw `Set-Cookie`
headers, since a cookie API keyed by name (such as `response.cookies.set` in
Next.js) keeps only the last write for each name, and don't pass them to
`applyCookieWrites`, which would drop the current session from the request.

## Ended sessions in your own proxy [#ended-sessions-in-your-own-proxy]

An access token stays valid until it expires, even after its session is
signed out elsewhere, revoked or deleted. `bs.proxy({ endedSession })` in
Next.js checks for that on page loads. A proxy or middleware that runs its
own `@supabase/ssr` client uses the same check through `sessionStatus` and
`clearSessionCookies` from `better-supabase/ssr` or `better-supabase/server`:

```ts
import { clearSessionCookies, sessionStatus } from "better-supabase/ssr";

const status = await sessionStatus(request, { url, publishableKey });
if (status === "ended") {
  const response = NextResponse.redirect(
    new URL("/login?reason=session_ended", request.url),
  );
  return clearSessionCookies(request, response, { url });
}
```

`sessionStatus` takes a request or an access token. From a request it reads
the bearer token, then the session cookie (`cookieName` overrides
`sb-<project>-auth-token`). It asks Auth (`GET /auth/v1/user`) and returns:

| Status      | When                                                                                          |
| ----------- | --------------------------------------------------------------------------------------------- |
| `"ended"`   | Auth answers 401 or 403: the session was signed out, revoked or deleted                       |
| `"banned"`  | Auth answers 401 or 403 with `error_code: "user_banned"`: an admin banned the account         |
| `"active"`  | Auth answers 2xx                                                                              |
| `"unknown"` | No token, an expired token (refresh it first), a network failure, a timeout or another status |

Send `"banned"` to a page that explains the suspension instead of the sign-in
page, and clear the cookies the same way as for `"ended"`. Auth reports
`user_banned` only while the session still exists: `suspendAccount` keeps the
sessions for that reason, and a ban whose sessions were deleted reads as
`"ended"` ([Suspending an account](/docs/auth/account-deletion#suspending-an-account)). When a refresh
fails because the user is banned, `refreshSession` returns
`{ ok: false, reason: "rejected", code: "user_banned" }`, and `code` carries
the Auth `error_code` of any rejected refresh. Treat `"unknown"` as active, so an Auth outage never signs anyone out. Each
call costs one Auth request, so run it on page loads only, not on prefetches
or assets (`shouldCheckSession` from `better-supabase/next` makes that
choice for Next.js). `fetch`, `timeoutMs` (5000 by default) and `now` are
optional.

`clearSessionCookies(request, response, { url, cookieName?, cookie? })`
appends `Max-Age=0` writes for every chunk of the session cookie the request
carries, plus `AUTH_CACHE_HEADERS`, and returns the same response. Its
headers must be mutable: `NextResponse.redirect` and `NextResponse.next` are,
`Response.redirect` is not. Pass `cookie` with the `domain` or `path` the
session was written at when it is not the default.

## Claims in the session [#claims-in-the-session]

The access token inside the cookie carries every claim your custom access
token hook adds, so the session cookie grows with them and every request
sends them twice (cookie and `Authorization` header). Keep ids and roles in
the token, validate and type them with
[`betterSupabase.claims(schema)`](/docs/auth#typed-claims), and let
`better-supabase doctor --as <user id>` warn when they pass 2 KB, and when the
claims an [authorization provider's](/docs/extending/authorization-providers)
hook budgets pass that budget ([BS405](/docs/cli/doctor#bs405)).