Overview
One resolver for every caller, local verification, and refreshes only where they belong.
Every server entry point in better-supabase starts with the same question:
who is calling? resolveAuth(request) answers it in this order:
- your own
AuthResolvers (API keys, third-party auth); Authorization: Bearer <jwt>, for APIs, MCP servers and mobile apps;- the
@supabase/ssrsession cookie, for browsers.
import { resolveAuth } from "better-supabase/server";
import { loadEnv } from "better-supabase/env";
const env = loadEnv();
const { auth } = await resolveAuth(request, { env });
switch (auth.kind) {
case "user": // auth.user.id, auth.claims, auth.token
case "service": // a secret key, when `secret: true`
case "anon": // auth.reason: 'none' | 'expired' | 'signed_out' | 'refresh_failed'
case "invalid": // auth.reason: 'token' (did not verify) | 'claims' (failed betterSupabase.claims) | 'actor' (malformed act chain): answer 401
}No needless work
- A valid token costs no network call. It is verified locally against
the project's JWKS (
<url>/auth/v1/.well-known/jwks.json), which is fetched once and cached. - Nothing is written while the token is valid: no cookie churn, no
Set-Cookieon every response. - A Bearer token that fails verification is
invalid, never silently downgraded to anonymous.
Anonymous users
A user from signInAnonymously() is a user with is_anonymous: true in
the token, and toSession(auth).anonymous is true. Every adapter's guard
refuses these users with a 403 (code: "ANONYMOUS_USER") unless allow lists
'anonymous', so a guest session never reaches a route meant for accounts.
The realtime-tables SQL module sends them no change signals.
allow | Full users | Anonymous sign-ins | No session |
|---|---|---|---|
['user'] (the default) | admitted | 403 ANONYMOUS_USER | 401 |
['user', 'anon'] | admitted | 403 ANONYMOUS_USER | admitted |
['user', 'anonymous'] | admitted | admitted | 401 |
['anonymous'] | 403 | admitted | 401 |
'anon' is the caller without a session and never admits an anonymous
sign-in; until 0.5.1 it did. Draw the same line in your RLS policies: a
policy that should not serve a guest session checks
(select (auth.jwt() ->> 'is_anonymous')::boolean) is not true.
app.use("/cart/*", bs.middleware({ allow: ["user", "anonymous"] }));Use asymmetric signing keys
Local verification needs asymmetric JWT signing keys (ES256 or RS256), whose
public half is in the JWKS. Hosted projects use them by default; for the local
stack, better-supabase keys creates signing_keys_path and better-supabase doctor warns when it is missing. With a legacy HS256 secret there is no
public key to check against. This is what lets the Next.js proxy, Server
Components and 'use cache: private' session reads stay free of network calls
(Cache Components).
Refreshing
An expiring cookie session is refreshed only when you pass refresh: true,
which you should only do where cookies can be written: the proxy or
middleware in front of page loads and server actions. With refresh: true
the session is refreshed when its token expires within leeway seconds (60
by default). Everywhere else the token stays valid until its exp, and only
an expired session reads as { kind: 'anon', reason: 'expired' }.
When it does refresh:
- concurrent requests carrying the same refresh token share one request to Supabase Auth, and a finished refresh is reused for ten seconds, so parallel page loads and prefetches converge on one session instead of tripping refresh-token reuse detection;
- the new session is written in the exact
@supabase/ssrformat, stale cookie chunks are expired, and the response getsno-storeheaders so no CDN caches one user's session for another; requestCookiesholds the cookies the rest of the request should see, so Server Components rendered after the proxy use the new token;- a rejected refresh token signs the user out (cookies cleared), and the
rejection is reused for ten seconds as well; a network failure keeps the
session so the next request can retry, and a token refreshed early (inside
leeway) still resolves the user until it expires, otherwise the caller isanonwithreason: "refresh_failed"; - a refresh that takes longer than
refreshTimeoutMs(5000 by default) counts as a network failure, so a slow Auth server never holds up the proxy.
const resolution = await resolveAuth(request, { env, refresh: true });
const response = await next(request, resolution.requestCookies);
return resolution.apply(response); // Set-Cookie + Cache-ControlThe /next adapter wires this up for you in proxy().
Refresh races across instances
Single-flight works per server instance. Across instances Supabase Auth's
refresh-token reuse interval (10 seconds by default) makes concurrent
refreshes safe. Keep it enabled; better-supabase doctor checks it.
Custom credentials
AuthResolver is an extension point. Return an AuthState when the request
carries something you understand, undefined otherwise:
const apiKeys: AuthResolver = {
name: "partner-api-keys",
async resolve(request) {
const key = request.headers.get("x-api-key");
if (!key) return undefined;
const partner = await lookupPartner(key);
return partner ? { kind: "service", keyName: partner.id } : undefined;
},
};
await resolveAuth(request, { env, resolvers: [apiKeys] });Resolvers may leave reason out of an invalid state; it counts as
'token'. Users they return go through the claims schema like any other.
Typed claims
Claims your custom access token
hook
adds are typed unknown until you describe them. betterSupabase.claims(schema) takes
any Standard Schema (zod, valibot, arktype) and
returns a new definition whose servers validate the verified claims and
whose sessions are typed by the schema's output:
import * as v from "valibot";
export const Claims = v.looseObject({
tenant_id: v.optional(v.pipe(v.string(), v.uuid())),
roles: v.optional(v.array(v.string()), []),
memberships: v.optional(
v.array(
v.looseObject({
scope: v.string(),
id: v.string(),
roles: v.array(v.string()),
}),
),
[],
),
});
export const betterSupabase = defineSupabase(schema)
.claims(Claims)
.use(tenant<v.InferOutput<typeof Claims>>());The tenant plugin, current_tenant_id() and the storage and realtime policies
read the active tenant from tenant_id (or app_metadata.tenant_id).
claims.tenant in the config renames it for all of them at once. Memberships
have the shape { scope, id, roles }, and roles and memberships are never
read from user_metadata, which users can edit. Use v.looseObject so claims
your schema doesn't list, such as fields another hook adds, stay on the
session. claims(schema) takes any Standard Schema, so an authorization
library can publish the schema for the claims its hook writes, and you pass
that one instead.
createServer,createNext,createHonoand friends validate after the signature check, on every request, including tokens served from the verification memo. A token whose claims fail resolves to{ kind: 'invalid', reason: 'claims' }with anunauthorizederror (code: 'CLAIMS_INVALID') that names the failing paths, never the values. The cookie path does not try a refresh for it, since a new token from the same hook would fail the same way.- The output is merged over the payload, so
sub,expand the other JWT claims stay, and defaults such asroles: []fill in. The merge is one level deep: a nested object in the schema, likeapp_metadata, replaces the payload's, so describe it withv.looseObject()(or your library's equivalent) to keep its other keys. ctx.auth.claims,await bs.session()and theuseSessionfromcreateHooks<typeof bs>()are typedJWTClaims & Claims. The standaloneuseSession<Claims>()takes the type explicitly.tenant<Claims>({ claim })only accepts dotted paths to string claims, such as'tenant_id'or'app_metadata.tenant_id'. Withoutclaimit readsclaims.tenantfrom the generated schema.
Keep the schema small: it runs on every request, and anything the hook
doesn't always set should be optional or have a default.
better-supabase doctor checks the hook itself: its grants
(BS404), that it is stable with an empty
search_path, and with --as <user id> how large the claims it returns are
(BS405).
When claims change
Claims are copied into the access token when Auth issues it, and verified
locally after that. When you change a user's app_metadata, their
memberships, or anything the hook reads, the user's current token still
carries the old values until it is refreshed: up to the token's lifetime (an
hour by default), or until the proxy refreshes the session. The same goes for
auth.jwt() in policies. Revoking a role therefore takes effect on the next
refresh, not immediately.
When that window matters, read the source of truth instead of the claim:
policies can check the memberships table (member_organization_ids() from the
SQL modules does). After a change the user made themselves,
such as joining an organization, the browser can call
supabase.auth.refreshSession() to pick up the new claims at once. Shorter
token lifetimes narrow the window for everyone
(BS402).
Profile
user_metadata holds what users say about themselves: a display name, an
avatar, a theme. betterSupabase.userMetadata(schema) parses it into a typed
session.profile for rendering:
export const Profile = v.object({
display_name: v.optional(v.string()),
avatar_url: v.optional(v.pipe(v.string(), v.url())),
});
export const betterSupabase = defineSupabase(schema)
.claims(Claims)
.userMetadata(Profile);const session = await bs.session();
if (session.kind === "user") {
return <h1>Hello, {session.profile?.display_name ?? session.user.email}</h1>;
}Any signed-in user can change their own metadata with auth.updateUser(), so
the profile is for display only. Roles, memberships, the tenant and every
policy come from the verified claims, never from profile or
user_metadata.
- The schema runs after the claims schema, on the
user_metadataclaim of the verified token. Metadata that fails it leavesprofileundefined and logs one warning per schema through theloggerofdefineSupabasewith the failing paths, never the values. The request still resolves as the user. session.profileis typedProfile | undefinedinctx.auth,bs.session()and theuseSessionfromcreateHooks<typeof bs>().- Supabase Auth copies
user_metadatainto the access token. A custom access token hook must keep that claim forprofileto be set. The claims budget (BS405) counts it, so keep large or frequently changing fields in aprofilestable instead.
Identity for repositories
authContext(auth) turns an auth state into the repository context: a typed
actor (user, service or anon) and the JWT claims. The
actor and tenant plugins
read it.
Last updated on