Session cookies
Read and write the @supabase/ssr cookie format from any framework.
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.
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 outwriteSession 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
@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:
const writes = writeSession(cookies, name, nextSession, cookieOptions, {
encode: "tokens-only",
});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 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
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:
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
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:
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). 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
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), and let
better-supabase doctor --as <user id> warn when they pass 2 KB, and when the
claims an authorization provider's
hook budgets pass that budget (BS405).
Last updated on