# createServer

> Repositories bound to the caller, plus explicit admin and acting-as identities.

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

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

```ts title="src/lib/supabase/server.ts"
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 [#per-request]

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

Each 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()`](/docs/frameworks/other#write-an-adapter) 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:

```ts
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 [#explicit-identities]

```ts
// 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](/docs/auth/impersonation).

`forContext(context)` does the same for a context recorded earlier, such as
`job.context` from the [jobs block](/docs/blocks/jobs#actor-and-tenant). 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:

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

```ts
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](/docs/guides/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 [#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:

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

```ts
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 [#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](/docs/frameworks/mcp), lists both:

```ts
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 [#shared-secret-tokens]

[Supabase Lite](/docs/platform/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 [#request-headers]

`headers` adds headers to every Supabase request made for a caller, for
tracing, rate limits or log fields:

```ts
export const bs = createServer(betterSupabase, {
  headers: (request) => ({ "x-channel": "web" }),
});
```

## Request and correlation ids [#request-and-correlation-ids]

Every context carries a request id and a correlation id, which the
[audit module](/docs/blocks/sql#request-and-correlation-ids) 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:

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

```ts
const ctx = server.contextFor(auth, {
  requestId: job.id,
  correlationId: job.data.correlationId,
});
```

`ctx.requestId` and `ctx.correlationId` hold the ids, for an
[audit event](/docs/blocks/audit#recording-events) written with a
service-role transport or a log line.

## Active tenant [#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](/docs/plugins/tenant),
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](/docs/blocks/access#the-active-tenant).

```ts
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](/docs/blocks/access#the-active-tenant)).

## Machine callers [#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`:

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