createServer
Repositories bound to the caller, plus explicit admin and acting-as identities.
betterSupabase from defineSupabase is isomorphic and holds no secrets.
createServer(betterSupabase) is its server half: it loads the env, resolves callers and
hands out repositories bound to an identity.
import { createServer } from "better-supabase/server";
import { createPostgres } from "better-supabase/postgres";
import { betterSupabase } from "./index";
export const bs = createServer(betterSupabase, {
postgres: createPostgres(), // optional: enables ctx.sql and actingAs()
});Per request
const ctx = await bs.context(request);
ctx.auth; // the resolved AuthState
ctx.db; // repositories over PostgREST as the caller (RLS applies)
ctx.sql; // the same repositories over direct Postgres as the caller
ctx.supabase; // a stateless supabase-js client for storage, functions, …
ctx.apply(response); // refreshed session cookies, and bs-primary-until after a write
ctx.cookies(); // the same cookies as CookieWrite objects, for a framework's cookie APIEach client is built on first access. For a signed-in user, ctx.db runs on
a bare PostgREST client that carries the user's token, so a request that only
queries data never builds the Realtime, Storage and Auth clients. Reading
ctx.supabase or ctx.db.$client builds the full supabase-js client.
context(request) resolves a request once: later calls with the same
refresh and cookies options return the same context, so nested
middleware and several Server Components share it. Calls that pass
tenant, stats, pinnedUntil or support build a new one.
By default context() never refreshes; run the proxy for that. For routes
that browsers call with cookies outside a proxy, context(request, { refresh: true })
refreshes an expiring session; send the new cookies with
ctx.apply(response), which also sets bs-primary-until after a write when
read replicas are configured. The Hono, edge and oRPC (bs.fetchHandler)
adapters do this for you, and so does
handle() in your own. To verify
a bare access token (a WebSocket's, a queue message's), use
resolveToken(bs, token). The supabase-js client is built from the verified
token, so it makes no auth calls and keeps no state between requests.
To build several contexts from one request without verifying the token again, resolve once and pass the resolution:
const resolution = await bs.resolve(request);
const ctx = bs.contextFromResolution(resolution, request);When env.jwksUrl is set and auth.jwks is not, createServer fetches the
JWKS right away, so the first request verifies its token without waiting for
it. Pass prefetchJwks: false to turn that off; it is off under
NODE_ENV=test.
Explicit identities
// Service role: bypasses RLS. The actor is `service`, so audit columns say so.
await bs.admin().customers.count();
// A user, with RLS, without their token: jobs, webhooks, support tooling.
await bs.actingAs(userId, { tenant_id: organizationId }).customers.findMany();actingAs runs over direct Postgres with the user's claims set for the
transaction, the way PostgREST does it, so policies see
auth.uid() = userId. It needs postgres. For support work, pass
{ actor, reason } as a third argument to record the admin: see
Impersonation.
forContext(context) does the same for a context recorded earlier, such as
job.context from the jobs block. It runs
as the context's user, sets its tenant as tenant_id and as the
better_supabase.tenant setting, and keeps an impersonating admin in act.
It returns a Result, which fails with forbidden when the context has no
user, so a job never falls back to the service role:
const db = await bs.forContext(job.context).orThrow();A job has no token, so claims that a custom access token hook adds (a role,
an organization list) are missing. claimsFor rebuilds them when the job
runs; sub, role, tenant_id and act always come from the context:
export const bs = createServer(betterSupabase, {
postgres: createPostgres(),
claimsFor: async (userId, context) => ({
user_role: await roleOf(userId, context.tenant),
}),
});See Jobs, webhooks and agents without a session.
dbFor(auth) and supabaseFor(auth) bind to an auth state you already
have, for example one resolved in a proxy.
From a verified token to a context
When something else already verified the caller (a gateway, a proxy, a queue
message that carries the token), skip a second verification and build the
context from the result. resolveToken(bs, token) verifies a bearer token
once and returns the AuthState; bs.contextFor(auth) turns any
AuthState into the same context bs.context(request) returns, with db,
supabase and the caller's claims:
import { resolveToken } from "better-supabase/server";
const auth = await resolveToken(bs, message.accessToken);
const { db } = bs.contextFor(auth);
await db.customers.findMany();Without a server, pass the claims you verified yourself to connect.
authContext(auth) builds the context from an AuthState; a hand-built one
needs at least claims, and actor and tenant when the actor() and
tenant() plugins should fill them:
const db = betterSupabase.connect(supabase, {
claims,
actor: { id: claims.sub, kind: "user", role: "authenticated" },
tenant: claims.tenant_id,
});Services read the caller from db.$context, not from the client: db.$context.claims
holds the verified claims and db.$context.actor the actor, so domain code
never decodes the token again or reaches for db.$client.
Token audience
createServer(betterSupabase, { auth: { audience } }) accepts only tokens
whose aud claim matches; without it the audience is not checked. Supabase
Auth issues aud: "authenticated" to signed-in users. A resource server that
also takes OAuth access tokens issued for its own URL, such as an
MCP server, lists both:
export const bs = createServer(betterSupabase, {
auth: { audience: ["authenticated", "https://api.example.com/mcp"] },
});issuer works the same way for the iss claim.
Shared-secret tokens
Supabase Lite signs tokens with HS256 and no key id.
createServer(betterSupabase, { backend: "lite" }) verifies those against
SUPABASE_JWT_SECRET, checking the signature, exp, nbf, aud and iss
locally, and keeps verifying tokens that carry a kid through the JWKS.
liteDriver names the Lite driver, so $rpc on SQLite returns an
unsupported error instead of a request.
Request headers
headers adds headers to every Supabase request made for a caller, for
tracing, rate limits or log fields:
export const bs = createServer(betterSupabase, {
headers: (request) => ({ "x-channel": "web" }),
});Request and correlation ids
Every context carries a request id and a correlation id, which the
audit module records on each
row change and event. ctx.db sends them as the x-request-id and
x-correlation-id headers, and ctx.sql sets them as the transaction-local
better_supabase.request_id and better_supabase.correlation_id settings.
The request id is the incoming request's x-request-id when it is valid, else
a new UUID, kept for every context built from the same Request. The
correlation id is the incoming x-correlation-id, else the request id.
An id is valid when it has 1 to 128 characters from A-Z, a-z, 0-9 and
. _ : ; , @ / + = -; anything else is dropped. The ids are log metadata: a
client can send any value, so never use them to decide access.
requestIds changes the header names, ignores the incoming headers, or turns
the ids off:
export const bs = createServer(betterSupabase, {
requestIds: {
requestHeader: "x-vercel-id",
correlationHeader: "x-correlation-id",
incoming: true,
generate: true,
},
});| Option | Default | What it does |
|---|---|---|
requestHeader | x-request-id | the header the request id is read from and sent as |
correlationHeader | x-correlation-id | the header the correlation id is read from and sent as |
incoming | true | reuse valid ids from the incoming request's headers |
generate | true | generate a request id when the incoming request has no valid one |
requestIds: false reads and generates nothing. Set the audit module's
requestIdHeader and correlationIdHeader options to the same header names.
A job without a request passes its own ids, and the headers option still
wins over the id headers:
const ctx = server.contextFor(auth, {
requestId: job.id,
correlationId: job.data.correlationId,
});ctx.requestId and ctx.correlationId hold the ids, for an
audit event written with a
service-role transport or a log line.
Active tenant
Apps that pick the tenant from the URL or a profile, rather than a claim,
pass tenant. It returns the tenant id for a request, or undefined. The
result becomes context.tenant for the tenant() plugin,
the better_supabase.tenant setting for ctx.sql and the x-bs-tenant
header (TENANT_HEADER) for ctx.db, which current_tenant_id() reads with
sql.modules.access.activeTenant: 'resolver'. See
The active tenant.
export const bs = createServer(betterSupabase, {
tenant: (request) => request.headers.get("x-organization-id") ?? undefined,
});
const ctx = await bs.context(request, { tenant: organizationId });context(request, { tenant }) overrides the resolver for one call.
contextFromResolution can't wait for an async resolver, so it throws unless
you pass { tenant }. Under createNext, Server Components and actions
have no request URL; pass the tenant from route params with
bs.context({ tenant }) or bs.cached({ tenant }) (see
The active tenant).
Machine callers
Secret keys sent as apikey are rejected unless you enable them. Give a list
of key names to accept only some of the keys in SUPABASE_SECRET_KEYS:
// SUPABASE_SECRET_KEYS={"default":"sb_secret_…","cron":"sb_secret_…"}
export const bs = createServer(betterSupabase, { auth: { secret: ["cron"] } });
// ctx.auth is { kind: 'service', keyName: 'cron' }; guard with allow: ['service']secret: true accepts any configured key. The admin client is created on the
first admin() call, so servers that never use it don't need a secret key.
For a service caller ctx.db runs with the secret key, so RLS doesn't apply,
and ctx.sql is undefined: there is no user to run as. Use bs.admin()
for privileged repository work, or bs.actingAs(userId) to run over Postgres
as a particular user.
Pass fetch (for example tracedFetch() from better-supabase/otel) to send
every PostgREST and supabase-js request through it. Auth refreshes use
auth.fetch.
Last updated on