# Supabase Server SDK Reference ## Introduction `@supabase/server` is a framework-agnostic library for authenticating requests in server-side JavaScript environments. It verifies JWTs, resolves Supabase API keys, and creates pre-configured Supabase clients — exposing everything through a single `SupabaseContext` that is identical regardless of which adapter or primitive produced it. Adapters for Hono, H3, Elysia, and NestJS are included. You can also compose the lower-level primitives directly for custom frameworks or edge runtimes. Not sure this is the right package? See [which package to use](https://supabase.com/docs/guides/auth/choosing-a-server-package) — for cookie-based sessions in SSR frameworks, use [`@supabase/ssr`](https://supabase.com/docs/guides/auth/server-side) instead. ## Installing ### Install as a package Install `@supabase/server` via your package manager. ```sh Terminal npm install @supabase/server ``` ```sh Terminal yarn add @supabase/server ``` ```sh Terminal pnpm add @supabase/server ``` ### Use via JSR (Deno / Bun) `@supabase/server` is also published to [JSR](https://jsr.io/@supabase/server) for Deno and Bun environments. ```sh Terminal deno add jsr:@supabase/server ``` ```sh Terminal bunx jsr add @supabase/server ``` ### Optional peer dependencies on Deno Some entry points rely on optional peer dependencies. `@supabase/server/middleware/postgres` and `@supabase/server/middleware/postgres-admin` need `pg`. Deno resolves an optional peer only when your own code imports it. A pin in `deno.json` alone is not enough: `deno check` passes and the function fails at startup with `Could not find package 'pg'`. Add a bare import once, at the top of your entry module — no separate `deno add npm:pg` or `package.json` entry is needed. `deno info` lists `npm:/pg@...` once the package is in the module graph. Deno 2.9 and later apply a minimum dependency age to npm packages. To use a release published the same day, pass `--minimum-dependency-age 0` to `deno check`. ```ts index.ts import 'pg' import { withPostgresClient } from '@supabase/server/middleware/postgres' ``` ```sh Terminal deno info index.ts | grep "npm:/pg@" ``` ## Usage examples Each example is a Fetch handler. Deno, Bun, and Supabase Edge Functions run the `export default { fetch }` shape as is. Cloudflare Workers also need the `nodejs_compat` flag or the `env` option, so `withSupabase` can read your project's URL and keys. On Node, use an adapter or the primitives with your framework. ### Protect an endpoint with a user JWT `withSupabase` wraps your handler. With `auth: 'user'`, it checks the caller's JWT before your handler runs. A request without valid credentials gets a JSON error response, and your handler never runs. `withSupabase` also answers every `OPTIONS` request with `204` and adds CORS headers to every response. Set `cors: 'disabled'` when your framework handles CORS. `ctx.supabase` is scoped to the caller, so Row Level Security policies apply. `ctx.supabaseAdmin` bypasses RLS. Use it only for work that needs full database access. ```ts import { withSupabase } from '@supabase/server' export default { fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => { const { data } = await ctx.supabase.from('todos').select() return Response.json(data) }), } ``` ### Serve a public endpoint `auth: 'none'` lets every request through. `ctx.userClaims` is `null`, and `ctx.supabase` runs as an anonymous client. Supabase Edge Functions check for a valid JWT on every request by default. For a function that uses `auth: 'none'`, `auth: 'publishable'`, or `auth: 'secret'`, turn that check off in `supabase/config.toml`. The `auth` setting on `withSupabase` then decides who gets in. ```ts import { withSupabase } from '@supabase/server' export default { fetch: withSupabase({ auth: 'none' }, async () => { return Response.json({ status: 'ok', time: new Date().toISOString() }) }), } ``` ```toml supabase/config.toml [functions.my-function] verify_jwt = false ``` ### Build the context yourself `createSupabaseContext` runs the same auth checks as `withSupabase` but returns `{ data, error }` instead of a response. Use it inside a framework route handler, or anywhere you want to shape the error response yourself. On success, `data` is the same `SupabaseContext` that `withSupabase` passes to your handler. On failure, `error.status` holds the HTTP status to send. `createSupabaseContext` does not handle CORS. ```ts import { createSupabaseContext } from '@supabase/server' export default { fetch: async (req: Request) => { const { data: ctx, error } = await createSupabaseContext(req, { auth: 'user' }) if (error) { return Response.json(error.toJSON(), { status: error.status }) } const { data } = await ctx.supabase.from('todos').select() return Response.json(data) }, } ``` ## Frameworks `@supabase/server` middleware runs inside Hono, H3, Elysia, NestJS, and TanStack Start through a bridge: one file you copy into your project. The bridge runs a `@supabase/middleware` entry array in the framework's own middleware slot. Your route handlers then read `supabase`, `jwtClaims`, and any other key the entries contribute from the place your framework keeps per-request state. The framework adapters (`@supabase/server/adapters/*`) predate the middleware engine. They are deprecated and will be removed on December 1, 2026. The bridges on this page replace them. If you use an adapter today, read [Moving off the adapters](https://supabase.com/docs/reference/server/frameworks#moving-off-the-adapters) before you change anything. One step in that migration can open an endpoint up without an error. The bridges need `@supabase/server` 1.6.0 or later and Node 22 or later. `withRequiredClaims` shipped in 1.5.1 and the entry form of `withSupabase` in 1.6.0. Releases before 1.5.1 have no `/middleware/*` exports. The bridge file imports `@supabase/middleware`, so add it to your project as a direct dependency. `@supabase/server` depends on it, but a strict package manager such as pnpm does not let your code import a transitive dependency. ### What a bridge gives you An entry does three jobs. How many of them survive depends on the framework. - Context. The entries contribute typed keys such as `supabase` and `jwtClaims`, and your handler reads them. - Short-circuit. An entry can return a `Response`, and your route never runs. That is how `withRequiredClaims` rejects a request. - Response phase. An entry written as `async function*` can `yield`, see the response on the way out, and rewrite it. `withCors` needs this to stamp headers. | Framework | Context | Short-circuit | Response phase | Pattern | | -------------- | ------- | ------------- | -------------- | ----------------- | | Hono | Yes | Yes | Yes | middleware slot | | H3 / Nuxt | Yes | Yes | Yes | middleware slot | | Elysia | Yes | Yes | Yes | wrap `app.handle` | | NestJS | Yes | Yes | No | guard | | TanStack Start | Yes | Yes | Yes | middleware slot | If you only read `supabase` inside a handler, every row works for you. Ignore the response-phase column. Each bridge folds the entry array once, when you call it, so entries keep their state across requests. Each bridge also checks the array at compile time. Two entries that contribute the same key, or an entry whose prerequisite is missing, fail to compile at the call site. A gated group of routes takes `withRequiredClaims()` and a public group takes `withClaims()`. The two cannot share an array, because both contribute `jwtClaims`. Each framework scopes an array to a group of routes in its own way. | Framework | One array per route group | | -------------- | ------------------------------------------------------------------------------------------------------------- | | Hono | One sub-app per array, mounted with `app.route()`. A second `app.use()` statement is not enough. | | H3 / Nuxt | `app.use('/path', toH3(entries))` before the routes under that path | | Elysia | One Elysia instance per array, each wrapped with `wrapElysia`, behind a fetch handler that dispatches by path | | NestJS | `@UseGuards()` on the controller or the handler | | TanStack Start | `.middleware([...])` on each server function or server route | The bridge files live in the server repository under [`examples/frameworks`](https://github.com/supabase/server/tree/main/examples/frameworks), next to a minimal app for each. Copy the file for your framework as is, comments included. Some lines look redundant and are not. The comments say why. ### Hono Copy [`hono/supabase-middleware.ts`](https://github.com/supabase/server/blob/main/examples/frameworks/hono/supabase-middleware.ts) into your project. `toHono` returns Hono middleware. Register it with `.use()` in a chain, and the contributed keys type through to `c.var` with no `Env` declaration of your own. Register it before the routes it gates. Hono applies middleware only to routes added after it, so a route added first runs with no gate and no error. `withSupabaseClient()` threads the generic through, so `c.var.supabase` is a `SupabaseClient`. Hono carries the contributed keys through the return value of a chained call. `app.use(...)` on one line and `app.get(...)` on the next typecheck the middleware but leave `c.var` untyped. The gate still runs, so a failing typecheck is the only signal. A second array for other routes goes in its own sub-app, mounted with `app.route()`. Each sub-app chains its own `.use()` into its routes. One line in the bridge looks redundant: `c.res` is cleared before it is assigned. When a response-phase entry returns a new `Response`, Hono's `res` setter copies the previous response's headers onto it, which reverts any header the entry rewrote. Clearing first makes the assignment final. Keep the line and its comment. On Cloudflare Workers the bridge seeds the pipeline with `c.env`, so `getEnv` inside the entries reads your bindings. ```ts import { Hono } from 'hono' import { withRequiredClaims } from '@supabase/server/middleware/required-claims' import { withSupabaseClient } from '@supabase/server/middleware/client' import { toHono } from './lib/supabase-middleware.js' const app = new Hono() .use('*', toHono([withRequiredClaims(), withSupabaseClient()])) .get('/todos', async (c) => { const { data, error } = await c.var.supabase.from('todos').select() if (error) return c.json({ error: error.message }, 500) return c.json(data) }) .get('/me', (c) => c.json({ id: c.var.jwtClaims.sub })) export default { fetch: app.fetch } ``` ```ts import { withClaims } from '@supabase/server/middleware/claims' const me = new Hono() .use('*', toHono([withRequiredClaims(), withSupabaseClient()])) .get('/', (c) => c.json({ id: c.var.jwtClaims.sub })) const feed = new Hono() .use('*', toHono([withClaims(), withSupabaseClient()])) .get('/', (c) => c.json({ anonymous: c.var.jwtClaims === null })) const app = new Hono().route('/me', me).route('/feed', feed) ``` ### H3 / Nuxt Copy [`h3/supabase-middleware.ts`](https://github.com/supabase/server/blob/main/examples/frameworks/h3/supabase-middleware.ts) into your project. `toH3` returns H3 middleware. H3 middleware return the response directly, so no workaround is needed. `event.context` is not generic. Hoist the entry array to a `const` and read the keys through `Contributions` from `@supabase/middleware`, or wrap that in a small typed accessor of your own. ```ts import { H3 } from 'h3' import type { Contributions } from '@supabase/middleware' import { withRequiredClaims } from '@supabase/server/middleware/required-claims' import { withSupabaseClient } from '@supabase/server/middleware/client' import { toH3 } from './lib/supabase-middleware.js' const entries = [withRequiredClaims(), withSupabaseClient()] as const const app = new H3() app.use(toH3(entries)) app.get('/todos', async (event) => { const { supabase } = event.context as Contributions const { data, error } = await supabase.from('todos').select() if (error) throw error return data }) export default { fetch: app.fetch } ``` ### Elysia Copy [`elysia/supabase-middleware.ts`](https://github.com/supabase/server/blob/main/examples/frameworks/elysia/supabase-middleware.ts) into your project. Elysia's lifecycle hooks run in the request phase only. A `.resolve()` hook has no `next()` and never sees the outgoing response. So `wrapElysia` composes the entries around `app.handle`, and `supabaseCtx()` is a plugin that hands the context back to your routes per request. `supabaseCtx()` types the route context. Pass the same tuple type that `wrapElysia` receives. Because the entries wrap the whole app, they apply app-wide. A second array for other routes needs its own Elysia instance, wrapped separately, with a fetch handler in front that dispatches by path to the wrapped apps. The two functions work only as a pair. If you serve the app without `wrapElysia`, with `app.listen()` or `export default app`, the entries never run and `supabaseCtx()` throws on every route. ```ts import { Elysia } from 'elysia' import { withRequiredClaims } from '@supabase/server/middleware/required-claims' import { withSupabaseClient } from '@supabase/server/middleware/client' import { supabaseCtx, wrapElysia } from './lib/supabase-middleware.js' const entries = [withRequiredClaims(), withSupabaseClient()] as const const app = new Elysia() .use(supabaseCtx()) .get('/todos', async (c) => { const { data, error } = await c.supabase.from('todos').select() if (error) throw error return data }) export default { fetch: wrapElysia(entries, (req) => app.handle(req)), } ``` ### NestJS Copy [`nestjs/supabase.guard.ts`](https://github.com/supabase/server/blob/main/examples/frameworks/nestjs/supabase.guard.ts) into your project. `toNestGuard` returns a guard class. A guard covers context and short-circuit. The response phase is not available: Nest's interceptors receive the controller's return value, not a `Response`, so an entry's `yield` has nothing to act on. `withCors` in the array stamps a short-circuit only: the 401 carries the headers and a successful response does not. A preflight never reaches a guard either. Guards run after routing, and no `OPTIONS` route exists, so the preflight 404s. CORS on Nest is `app.enableCors()`. Leave `withCors` out of the guard's array. Call `toNestGuard` once and reuse the class on every route, so the pipeline folds once. On a short-circuit the guard copies the entry's headers onto the response, then throws an `HttpException` with the entry's status and its `{ message, code }` body. The bridge builds a Web `Request` from Nest's request with the headers and method. The body is not forwarded. An entry that reads it sees an empty body and runs as if that were the payload, so a signature check or a body audit in the array passes with nothing checked. Put those in Nest middleware. A contribution whose key matches a property Nest's request already has, such as `body` or `query`, throws instead of overwriting it. `Injectable()` is applied as a call, so the file works without a decorator transform. The controller does not. Nest is built on decorators, so running the example needs swc, ts-node, or a build step. Node's built-in type stripping rejects the `@Controller()` line. Nest also answers a `POST` with 201 by default, where the other frameworks answer 200. ```ts import { Controller, Get, Req, UseGuards } from '@nestjs/common' import type { Contributions } from '@supabase/middleware' import { withRequiredClaims } from '@supabase/server/middleware/required-claims' import { withSupabaseClient } from '@supabase/server/middleware/client' import { toNestGuard } from './lib/supabase.guard.js' const entries = [withRequiredClaims(), withSupabaseClient()] as const const SupabaseGuard = toNestGuard(entries) @Controller('todos') export class TodosController { @Get() @UseGuards(SupabaseGuard) async list(@Req() req: Contributions) { const { data, error } = await req.supabase.from('todos').select() if (error) throw error return data } } ``` ### TanStack Start TanStack Start never had an adapter, so this section is integration guidance. The migration steps below do not apply to it. Copy [`tanstack-start/supabase-middleware.ts`](https://github.com/supabase/server/blob/main/examples/frameworks/tanstack-start/supabase-middleware.ts) into your project. `toTanStackStart` returns a request middleware. `request` is already a Web `Request`, and `next()` resolves to an object carrying the downstream `Response`, so the fit is close. Two details in the bridge carry the typing. `.server` is what types `context` downstream; the generic has no constraint, so leaving it off types the context as `undefined`. And `` keeps the tuple, so every `context.*` read stays typed. The engine buffers a request body only when it seeds the context itself. The bridge seeds, so it buffers too, and it does so in place. `next()` accepts `context` only, so the bridge cannot hand a different `Request` downstream, and Start gives the route handler the same object the middleware saw. The bridge installs cached readers on that object, and an entry and the handler read the same body. `createMiddleware({ type: 'request' })` is the right kind here. Its server function may return a `Response`, which is what lets an entry short-circuit, and `createServerFn().middleware([...])` accepts request middleware. On a server function, Start's fetcher returns any `application/json` body as the call's value without checking the status. It checks `response.ok` only when the body is not JSON. A 401 from `withRequiredClaims()` would resolve the caller's promise with `{ message, code }` where it expects its own result type: the same silent-200 class of failure as the auth trap, one layer further out. So when an entry short-circuits on a server function, the bridge rethrows it as an error carrying `status` and `code`. Server routes get the `Response` back unchanged. The file that calls `.middleware([...])` ships to the client, so the entry modules it references are client-reachable. Read configuration through `getEnv` per request and keep secrets out of module scope. Vite's dev server answers CORS on its own. A route with no `withCors` entry works in development and fails once the app is built and served. Compose `withCors` on every route a browser calls, and check the preflight against a production build. ```ts import { createServerFn } from '@tanstack/react-start' import { withRequiredClaims } from '@supabase/server/middleware/required-claims' import { withSupabaseClient } from '@supabase/server/middleware/client' import { toTanStackStart } from './lib/supabase-middleware.js' const supabase = toTanStackStart([withRequiredClaims(), withSupabaseClient()]) export const getTodos = createServerFn() .middleware([supabase]) .handler(async ({ context }) => { const { data, error } = await context.supabase.from('todos').select() if (error) throw error return data }) export const whoAmI = createServerFn() .middleware([supabase]) .handler(async ({ context }) => ({ id: context.jwtClaims.sub })) ``` ### Moving off the adapters Each adapter solved the same problem in a framework-specific way: build a `SupabaseContext` and stash it where the handler can reach it. That meant a published entry point, a peer dependency range, and a release cycle per framework, all to wrap one call. The engine replaced the model. A middleware is now an entry, a `(handler) => handler` wrapper over the Web `Request` and `Response` pair, and `withSupabaseClient()`, `withSupabaseAdminClient()`, `withRequiredClaims()`, and the rest are ordinary entries you compose. What remains is the bridge, and it belongs in your project, not in a package that has to track your framework's major versions. The adapters are deprecated. They still work today and will be removed on December 1, 2026. `withSupabase(config, handler)` from `@supabase/server` is not part of this change. Three changes come with the migration. The first can open an endpoint up. ### The auth trap The adapters rejected unauthenticated requests. `withClaims()` does not. Migrating an `auth: 'user'` endpoint onto it instead of `withRequiredClaims()` turns a 401 into a silent 200. `withSupabase({ auth: 'user' })` verified the caller's token before your handler ran and returned a 401 when it was missing. The entry that looks like its replacement does not. ```ts // Before: an anonymous request gets 401 and the handler never runs. app.use('*', withSupabase({ auth: 'user' })) // After, wrong: an anonymous request gets 200. The handler runs with // jwtClaims === null and an unauthenticated Supabase client. app.use('*', toHono([withClaims(), withSupabaseClient()])) // After, right: the same gate the adapter applied. app.use('*', toHono([withRequiredClaims(), withSupabaseClient()])) ``` `withClaims()` rejects a token that is present and invalid. A request with no credentials at all is not an error to it. It contributes `jwtClaims: null` and falls through, by design, because many pipelines want an anonymous path. Nothing throws and nothing logs. The endpoint returns 200 with whatever Row Level Security lets the anonymous role see, often an empty array. That reads like a data bug, and it can sit in production for a long time before anyone reads it as an auth bug. `withRequiredClaims()` from `@supabase/server/middleware/required-claims` is the required-caller counterpart. It verifies against the same project JWKS, rejects before your handler runs, and contributes non-null `jwtClaims`, so gated handlers read `jwtClaims.sub` with no `?.` fallback. The two are mutually exclusive. Both declare the `jwtClaims` key, so composing them is a compile-time conflict, not a fallback. Which endpoints are affected is written in the adapter config, not in the handler. That is what makes it easy to miss in review. | Adapter config | Rejected before | Replacement | | ---------------------------- | ------------------------- | --------------------------------- | | `auth: 'user'`, or no config | missing or invalid JWT | `withRequiredClaims()` | | `auth: 'none'` | nothing | nothing; the client entries alone | | `auth: 'publishable'` | missing or wrong `apikey` | none. Keep `withSupabase` | | `auth: 'secret'` | missing or wrong `apikey` | none. Keep `withSupabase` | A bare `withSupabase()` with no config was `auth: 'user'` and did reject. No composable gate exists for `publishable` or `secret`. Those endpoints stay on `withSupabase({ auth: 'publishable' })` or `withSupabase({ auth: 'secret' })`, which needs no changes. Hand-rolling a key check is how endpoints get opened up. Two details catch people who try. Those modes read the `apikey` header and never `Authorization`, so a caller sending `Authorization: Bearer ` gets a bare 401. And `auth: 'secret:'` also points `supabaseAdmin` at `secretKeys['']`, so that entry has to hold a real Supabase secret key. `withRequiredClaims` answers with the standard error payload, with the same codes `withSupabase({ auth: 'user' })` returns for the same request. | Request | Status | `code` | | ----------------------------------------------------- | ------ | --------------------- | | No `Authorization` header | 401 | `MISSING_CREDENTIALS` | | An `sb_*` API key in the `Authorization` slot | 401 | `UNUSABLE_CREDENTIAL` | | A token that fails verification | 401 | `INVALID_JWT` | | A token, but the JWKS could not be fetched | 500 | `JWKS_FETCH_FAILED` | | A token, but no JWKS source and no usable project URL | 500 | `JWKS_NOT_CONFIGURED` | The adapters emitted none of these. Each rejected in its own framework-native shape. Hono threw an `HTTPException`, which renders as `text/plain` with no `code`. H3 threw an `HTTPError`. Elysia surfaced a `SupabaseError` through your `onError` handler. Only the NestJS adapter threw `HttpException({ message, code }, status)`. A client that branches on the 401 status is fine. A client that parses the body needs rechecking. Neither entry answers a CORS preflight, and the short-circuits carry no CORS headers. For browser callers, compose `withCors` from `@supabase/middleware/cors` ahead of the gate. CORS is an entry like any other, so a route that composes no entries has no CORS. A public route a browser calls, such as a health check, needs its own array with `withCors` in it. Under `curl` the response is an ordinary 200; only a browser refuses it. ### Keep `withSupabase` where it fits `withSupabase(config, handler)` still does the verify-then-reject work for you, and it is not deprecated. It wraps a fetch handler rather than composing into a framework chain, so it does not slot into a bridge. If an endpoint is already a plain fetch handler, staying on `withSupabase()` is a legitimate end state. It is also the only way to get the full `SupabaseContext`, `userClaims` and `authMode` included, behind an auth gate. To compose other entries around it, `withSupabase({ auth: 'user' })` with no handler is a `pipeline` entry placed by position. Entries before it run ahead of the auth gate. Entries after it receive the full `SupabaseContext`. Nesting, as in `withSupabase(config, entry(handler))`, still works. The entry form needs 1.6.0 or later. ```ts import { pipeline } from '@supabase/middleware' import { withCors } from '@supabase/middleware/cors' import { withSupabase } from '@supabase/server' export default { fetch: pipeline( [ withCors({ origin: ['https://app.example.com'] }), withSupabase({ auth: 'user', cors: 'disabled' }), ], async (_req, ctx) => Response.json({ user: ctx.userClaims?.id }) ), } ``` `withSupabaseClient()` and `withSupabaseAdminClient()` throw an `EnvError` when configuration is missing, and the bridges do not catch it. The adapters turned it into a clean 500. The two fail at different moments. `withSupabaseClient()` throws from the entry chain, before your handler runs. `withSupabaseAdminClient()` builds lazily and throws on the first `supabaseAdmin` property access, inside your handler. Map both in your framework's error boundary. ### The shape change Adapters exposed one nested object. The entries contribute flat keys, one per middleware. ```ts // Before const { supabase, userClaims } = c.var.supabaseContext // After const supabase = c.var.supabase const jwtClaims = c.var.jwtClaims ``` For `supabase` and `supabaseAdmin` the rewrite is mechanical. For claims it is not. No entry contributes `userClaims`. `withClaims()` and `withRequiredClaims()` both contribute `jwtClaims`, the raw JWT payload, and the field names differ. | `userClaims` (adapters) | `jwtClaims` (middleware) | | ----------------------- | ------------------------ | | `.id` | `.sub` | | `.role` | `.role` | | `.email` | `.email` | | `.appMetadata` | `.app_metadata` | | `.userMetadata` | `.user_metadata` | A blanket rename of `userClaims` to `jwtClaims` fails to compile in TypeScript. In plain JavaScript, or behind an `as`, `jwtClaims.id` is `undefined` with no error, and `.id` is usually the value rows get written with. Rewrite each read against the table. ### Step by step Do the steps in order. The auth inventory comes before any code change, because the step that follows it is the one that can open an endpoint up. Follow the steps yourself, or hand them to a coding agent with the prompt at the end of this page. ### 1. Check your versions The bridges need `@supabase/server` 1.6.0 or later and Node 22 or later. Then decide whether you need the response phase: an entry that sees the outgoing response and can rewrite it, which is what CORS and header-stamping middleware do. The table at the top of this page says what survives per framework. If you only read `supabase` inside a handler, you do not need it. ```bash npm ls @supabase/server # 1.6.0 or later node --version # 22 or later ``` ### 2. Inventory every adapter registration Do this before editing anything. What you need is in the adapter config, not in the handlers, and it stops being visible the moment you start swapping imports. For each hit, find the `withSupabase(...)` call it feeds and write down its `auth` value. A bare `withSupabase()` with no config counts as `auth: 'user'`. Keep that list. Step 7 checks against it. ```bash grep -rn "@supabase/server/adapters" src/ ``` ### 3. Decide what replaces each auth value Use the table in "The auth trap" above. `auth: 'user'` and a bare `withSupabase()` become `withRequiredClaims()`. `auth: 'none'` needs no gate. `auth: 'publishable'` and `auth: 'secret'` have no composable gate: those endpoints stay on `withSupabase`, and leaving them unmigrated is the better outcome. Do not reach for `withClaims()` here. It lets anonymous requests through by design. ```ts // auth: 'user', or no config toHono([withRequiredClaims(), withSupabaseClient()]) // auth: 'none' toHono([withSupabaseClient()]) // auth: 'publishable' or auth: 'secret' // Stop. Keep withSupabase for this endpoint. ``` ### 4. Copy the bridge for your framework One file, yours to own from here on: `src/lib/supabase-middleware.ts`, or `src/lib/supabase.guard.ts` for NestJS. Copy it as is. Some lines look redundant and are not. The Hono `c.res` clear-then-assign is the clearest example, and it carries the comment explaining why. Keep the comments. ```bash npm install @supabase/middleware mkdir -p src/lib curl --fail -o src/lib/supabase-middleware.ts \ https://raw.githubusercontent.com/supabase/server/main/examples/frameworks/hono/supabase-middleware.ts ``` ### 5. Swap the registrations One at a time, using the decision from step 3. Add `withSupabaseAdminClient()` from `@supabase/server/middleware/admin-client` only where the old code read `supabaseAdmin`. It needs the secret key. ```ts // Before import { withSupabase } from '@supabase/server/adapters/hono' app.use('*', withSupabase({ auth: 'user' })) // After import { withRequiredClaims } from '@supabase/server/middleware/required-claims' import { withSupabaseClient } from '@supabase/server/middleware/client' import { toHono } from './lib/supabase-middleware.js' app.use('*', toHono([withRequiredClaims(), withSupabaseClient()])) ``` ### 6. Rewrite the call sites The adapters exposed one nested object. The entries contribute flat keys. The same shape applies to `event.context` (H3), the route context (Elysia), and `req` (NestJS). `userClaims` is not on that list. No entry contributes it. Rewrite each claims read against the field table in "The shape change" above. ```text c.var.supabaseContext.supabase -> c.var.supabase c.var.supabaseContext.supabaseAdmin -> c.var.supabaseAdmin c.var.supabaseContext.userClaims.id -> c.var.jwtClaims.sub ``` ```bash grep -rn "supabaseContext" src/ # expect no results ``` ### 7. Verify Three checks, in this order. The second is what this procedure exists for. Types: run the typecheck. Auth: for every endpoint step 2 recorded as rejecting, prove it still rejects. A clean typecheck says nothing about it. A `200` on the first call is the failure this page is arranged around. It means the gate is missing, not that the endpoint is healthy. Behavior: run your test suite, then exercise one migrated endpoint end to end. If it was behind `auth: 'user'`, sign in and confirm the handler sees the caller: `jwtClaims.sub` is the user id. The rejection body changed even when the status did not. See the code table in "The auth trap" above. ```bash npx tsc --noEmit BASE=http://localhost:3000 # no credentials -> expect 401 curl -s -o /dev/null -w "%{http_code}\n" $BASE/your-endpoint # a valid user token -> expect 200 curl -s -o /dev/null -w "%{http_code}\n" \ -H "Authorization: Bearer $TOKEN" $BASE/your-endpoint ``` ### 8. Clean up Anything left is either an endpoint you kept on purpose (the `publishable` and `secret` cases from step 3) or one you missed. Uninstall the framework peer dependency only if nothing else uses it. ```bash grep -rn "@supabase/server/adapters" src/ ``` ### Call-site cheat sheet | Before | After | | --------------------------------------------------------------- | -------------------------------------------------------------------- | | `import { withSupabase } from '@supabase/server/adapters/hono'` | `import { toHono } from './lib/supabase-middleware.js'` | | `app.use('*', withSupabase({ auth: 'user' }))` | `app.use('*', toHono([withRequiredClaims(), withSupabaseClient()]))` | | `app.use('*', withSupabase({ auth: 'none' }))` | `app.use('*', toHono([withSupabaseClient()]))` | | `c.var.supabaseContext.supabase` | `c.var.supabase` | | `c.var.supabaseContext.supabaseAdmin` | `c.var.supabaseAdmin` | | `c.var.supabaseContext.userClaims.id` | `c.var.jwtClaims.sub` (the field names change too; see the table) | | `event.context.supabaseContext.supabase` (H3) | `event.context.supabase` | | `req.supabaseContext.supabase` (NestJS) | `req.supabase` | The adapters took `auth: 'user' | 'publishable' | 'secret' | 'none'` and did two jobs with it: verified the credentials, and rejected the request when they were missing or wrong. Of the composable entries, only `withRequiredClaims()` does both, and only for user mode. If you want the adapter's exact verify-then-build behavior with no rewrite, keep the top-level `withSupabase()`. It is not deprecated, and as an entry it composes with `pipeline`. ### Hand it to an agent Paste this, and replace `hono` in the URL with `h3`, `elysia`, or `nestjs` (`supabase.guard.ts` for NestJS). ```text Migrate this project off @supabase/server's framework adapters (@supabase/server/adapters/*) onto @supabase/middleware entries. Work in the order below. Do not start at step 3. STEP 1: INVENTORY. Do this before editing anything. Grep for `@supabase/server/adapters` and list every withSupabase(...) registration it feeds, with that call's `auth` value. A bare withSupabase() with no config means auth: 'user'. Show me this list before you edit. STEP 2: DECIDE THE AUTH REPLACEMENT for each one. This is the step that can open an endpoint up, so do it deliberately: auth: 'user' (or no config) -> withRequiredClaims() from '@supabase/server/middleware/required-claims' auth: 'none' -> no gate needed auth: 'publishable'/'secret' -> STOP. There is no composable gate. Leave the endpoint on withSupabase and tell me about it. Do not hand-roll a key check. The adapters REJECTED unauthenticated requests. withClaims() does NOT: it contributes jwtClaims: null and falls through, turning a 401 endpoint into a 200 endpoint silently. Never use withClaims() as the replacement for auth: 'user'. Never compose withClaims() and withRequiredClaims() together; they share the jwtClaims key and that is a compile-time conflict. STEP 3: FETCH THE BRIDGE. Add @supabase/middleware as a direct dependency of the project; the bridge imports it. Then download https://raw.githubusercontent.com/supabase/server/main/examples/frameworks/hono/supabase-middleware.ts and save it as src/lib/supabase-middleware.ts, verbatim. Do not "improve", condense, or drop comments from it. STEP 4: SWAP each registration to the bridge called on an entry array, e.g. toHono([withRequiredClaims(), withSupabaseClient()]) For NestJS, call toNestGuard(entries) once, assign it to a const, and pass that const to every @UseGuards(). Import the client entries from '@supabase/server/middleware/client' and '@supabase/server/middleware/admin-client'. Only include withSupabaseAdminClient() where the old code actually read supabaseAdmin. STEP 5: REWRITE call sites from the nested shape to flat keys: c.var.supabaseContext.supabase -> c.var.supabase c.var.supabaseContext.supabaseAdmin -> c.var.supabaseAdmin Apply the equivalent for event.context / the Elysia context / req. userClaims is NOT one of these. No middleware contributes that key; c.var.userClaims does not exist. The entries contribute jwtClaims, the RAW JWT payload, with different field names: userClaims.id -> jwtClaims.sub userClaims.role -> jwtClaims.role userClaims.email -> jwtClaims.email userClaims.appMetadata -> jwtClaims.app_metadata userClaims.userMetadata -> jwtClaims.user_metadata Rewrite each read individually. Do NOT do a blanket rename; in plain JavaScript that leaves .id undefined with no error. When finished there must be no remaining reference to `supabaseContext`. STEP 6: VERIFY. Run the typecheck. Then, for every endpoint you listed in step 1 as rejecting, confirm an unauthenticated request still returns 401 and an authenticated one still returns 200. A passing typecheck does not prove this; check it separately. CONSTRAINTS - Do NOT remove the `c.res = undefined` line in the Hono bridge or its comment. It looks redundant and is not: when a response-phase entry returns a new Response, Hono merges the previous response's headers over it, reverting anything the entry rewrote. - On Hono, put a second entry array in its own sub-app mounted with app.route(). A second app.use() statement leaves c.var untyped. - On NestJS, do not put withCors in the guard's array. CORS is app.enableCors(). REPORT, as a table: every adapter registration you found, its old `auth` value, and whether the migrated version still rejects anonymous requests. Then list every file you changed and anything you could not migrate. ``` ## Middleware ## createSupabaseContext Creates a SupabaseContext directly from a request. Use this when you need the context without the full withSupabase wrapper — e.g., inside framework route handlers or custom middleware. Returns a result tuple instead of producing a `Response`. ### Examples #### User auth ```ts const { data: ctx, error } = await createSupabaseContext(request, { auth: 'user' }) if (error) { // `toJSON()` yields { source, code, message, hint, docs, details } return Response.json(error.toJSON(), { status: error.status }) } const { data } = await ctx.supabase.rpc('get_my_items') ``` ## fromSupabaseUrl Points `authorizationServer` at a Supabase project's Auth issuer. Use this off Supabase Edge Functions, where the app's own origin is unrelated to the Supabase project's, so the issuer cannot be derived from the request. ### Examples #### Example 1 ```ts import { withOAuthProtectedResource, fromSupabaseUrl } from '@supabase/server' withOAuthProtectedResource( { resourceServer: (req) => new URL(req.url).origin + '/api/mcp', authorizationServer: fromSupabaseUrl('https://abc123.supabase.co'), }, handler, ) ``` ## ident Quote a Postgres identifier — a table, column, or role name — for safe interpolation into SQL text. Bind parameters cover *values* and nothing else: `order by $1` sorts every row by the constant string `$1`, and `select $1 from t` selects a literal, not a column. Identifiers therefore have to reach the server as SQL text, which is the one place string-building is unavoidable. This makes that step safe by quoting the name and doubling any embedded quote, so the result is always exactly one identifier no matter what it contains. **It is not an allowlist.** Quoting a caller-supplied name yields a valid identifier, not a permitted one — `ident(req.query.sort)` cannot inject SQL but can still read a column the caller was never meant to see. Check the name against a fixed set you control first, then quote it. ### Examples #### Example 1 ```ts const SORTABLE = new Set(['created_at', 'title']) if (!SORTABLE.has(column)) throw new Error('unsupported sort column') const rows = await ctx.postgres.queryRaw( `select id, title from posts order by ${ident(column)} desc`, ) ``` ## PostgresApi The shape of `ctx.postgres` and `ctx.postgresAdmin`. Both halves expose the same surface — they differ in what runs around the query, not in how you call it. `withPostgresClient` wraps every query in a transaction that injects the caller's claims and drops to their role; `withPostgresAdminClient` runs it as-is, as the connection-string role. ## PostgresPoolOptions Pool sizing for `withPostgresClient` and `withPostgresAdminClient`. Two entries on the same connection string share a pool only when their options resolve to the same values. ## RequestClaims Minimal claims shape withPostgresClient needs on the upstream context. Satisfied both by `withSupabase`'s JWKS-verified `ctx.jwtClaims` and by the standalone `withClaims` middleware — `withPostgresClient` only reads `role` and serializes the whole object into `request.jwt.claims`. ## resourceMetadataResponse RFC 9728 OAuth Protected Resource Metadata response. Advertises the authorization server, resource URI, and bearer methods supported. URLs default to the Edge Functions derivation and throw off platform; pass `resource` / `authorizationServers` to override. ## unauthorizedResponse `401` response with a `WWW-Authenticate: Bearer resource_metadata="..."` header (RFC 9728). The metadata URL defaults to the Edge Functions derivation and throws off platform; pass `resourceMetadataUrl` to override for custom setups. ## withClaims Contributes `ctx.jwtClaims` by verifying the caller's Bearer token against the project JWKS — the same verification core `withSupabase` uses for its `user` auth mode. Use this when composing a standalone `pipeline([...], handler)` that is **not** wrapped by `withSupabase` — for example a Supabase-agnostic Edge Function that still wants the caller's verified claims available to a downstream middleware such as `withPostgresClient`. Inside `withSupabase`, the context already carries `jwtClaims`, so `withClaims` is unnecessary. Behavior: - No `Authorization: Bearer` token (or an `sb_*` API key in that position) → contributes `null`; the request proceeds as anonymous. - Token present but invalid → short-circuits with a 401 JSON response (`{ message, code }`, matching `withSupabase`'s error shape). - Token present but no JWKS configured → short-circuits with a 500 — verification is not optional; there is no decode-only mode. `withClaims` is not an auth gate. It never rejects a request that has no token. A pipeline like `[withClaims(), withSupabaseClient()]` accepts anonymous callers and is not the composable form of `withSupabase({ auth: 'user' })`, which rejects token-less requests with a 401. To require an authenticated caller, compose `withRequiredClaims` from `@supabase/server/middleware/required-claims` instead. The two entries share the `jwtClaims` key, so a pipeline picks "claims if present" or "claims required"; composing both is a compile-time conflict. ### Examples #### Standalone pipeline ```ts import { pipeline } from '@supabase/middleware' import { withClaims } from '@supabase/server/middleware/claims' import { withPostgresClient } from '@supabase/server/middleware/postgres' export default { fetch: pipeline([withClaims(), withPostgresClient()], async (req, ctx) => { const rows = await ctx.postgres.query`select id, title from posts` return Response.json({ rows, caller: ctx.jwtClaims?.sub ?? 'anon' }) }), } ``` ## WithClaimsConfig Configuration for withClaims. ## withOAuthProtectedResource Wraps a request handler with OAuth 2.1 Protected Resource behavior (RFC 9728). - Serves OAuth Protected Resource Metadata at `GET {resource}/oauth-protected-resource` (with permissive CORS, including the `OPTIONS` preflight, so browser-based clients can read it) - Enriches a `401` from the inner handler with `WWW-Authenticate: Bearer resource_metadata="..."`, unless the handler already set a `WWW-Authenticate` header (its value wins) - Passes any other path through to the inner handler unchanged (composition, not routing, decides what happens to it) - Answers a default URL it cannot derive with the JSON error response `withSupabase` returns for its own configuration failures (`500`, `x-supabase-server-error`); a throw from a configured `resourceServer` or `authorizationServer` function is the caller's and propagates The metadata route is matched on the path *suffix*, so **any** `GET` or `OPTIONS` ending in `/oauth-protected-resource` is answered here and never reaches the inner handler, at any depth. Other methods pass through. Zero-config on Supabase Edge Functions. Elsewhere OAuthProtectedResourceConfig.resourceServer is required and OAuthProtectedResourceConfig.authorizationServer falls back to `SUPABASE_URL`; one that cannot be resolved is reported as `MISSING_RESOURCE_SERVER` / `MISSING_AUTHORIZATION_SERVER`. Contributes `ctx.oauthProtectedResource` (the resolved metadata URL) to the downstream context. Nested under `withSupabase`, the key is typed on the handler's `ctx` when the outermost call is anchored with `satisfies FetchHandler` — see `withSupabase`'s type note. Placed directly after `withSupabase` with an auth mode that requires credentials, the composition is refused when the stack is built, since the gate would answer discovery and preflight before this middleware runs. ### Examples #### Supabase Edge Functions — zero config ```ts import { withOAuthProtectedResource, withSupabase } from '@supabase/server' Deno.serve( withOAuthProtectedResource( withSupabase({ auth: 'user' }, async (_req, { supabase }) => { const { data, error } = await supabase.from('items').select('*') if (error) throw error return Response.json(data) }), ), ) ``` #### Any other backend ```ts import { withOAuthProtectedResource, fromSupabaseUrl } from '@supabase/server' export default { fetch: withOAuthProtectedResource( { resourceServer: (req) => new URL(req.url).origin + '/api/mcp', authorizationServer: fromSupabaseUrl('https://abc123.supabase.co'), }, handler, ), } ``` #### A non-Supabase authorization server ```ts withOAuthProtectedResource( { resourceServer: 'https://api.example.com/mcp', authorizationServer: 'https://example.clerk.accounts.dev', }, handler, ) ``` ## withPostgresAdminClient Contributes `ctx.postgresAdmin` — a `pg` client that **bypasses RLS**, for full-table access. The direct-connection counterpart to `withSupabaseAdminClient`, and the deliberate opt-out from the guardrails `withPostgresClient` (`@supabase/server/middleware/postgres`) enforces. Queries run as-is, as the role in the connection string: no claim injection, no role switching, no wrapping transaction. Whatever that role may read, the caller may read. Unlike `withPostgresClient` this declares **no upstream prerequisite** — it never looks at `ctx.jwtClaims`, so it composes in any auth mode, including `auth: 'secret'` and `auth: 'none'`: ```ts import { pipeline } from '@supabase/middleware' import { withSupabase } from '@supabase/server' import { withPostgresAdminClient } from '@supabase/server/middleware/postgres-admin' pipeline([withSupabase({ auth: 'secret' }), withPostgresAdminClient()], handler) ``` Compose both halves when a handler needs each in turn — they share one pool, and `ctx.postgres` stays RLS-scoped regardless: ```ts pipeline([withSupabase({ auth: 'user' }), withPostgresClient(), withPostgresAdminClient()], handler) ``` > **Authorization is yours now.** RLS is not consulted, so any per-user > scoping has to be a `where` clause you write. Reach for > `withPostgresClient` unless you specifically need to cross user > boundaries. > **Runtime note.** `pg` needs raw TCP, so this runs on Node/Deno (including > the Supabase Edge runtime), **not** on Workers-style isolates. ## WithPostgresAdminClientConfig Configuration for withPostgresAdminClient. ## withPostgresClient Contributes `ctx.postgres` — an RLS-scoped `pg` client, the safe version of "authenticate, then query as the user". This is the direct-connection counterpart to `withSupabaseClient`, and its service-role companion is `withPostgresAdminClient` (`@supabase/server/middleware/postgres-admin`). Every query runs in its own short transaction that injects the caller's claims and drops to their role, exactly like PostgREST: ```sql begin; select set_config('request.jwt.claims', $claims, true); -- auth.uid() resolves set local role "authenticated"; -- RLS now enforces commit; ``` Everything is transaction-local, so nothing leaks onto the pooled connection. Only `authenticated` and `anon` are assumed. A token naming any other role — including `service_role` — is **refused** with a 500 and `code: 'UNSUPPORTED_ROLE'`, never silently downgraded to `anon`: running the query as the wrong identity would return zero rows and leave nothing to debug. Bypassing RLS is a separate, explicit opt-in: compose `withPostgresAdminClient`. > **Custom roles.** Supabase supports custom Postgres roles via the `role` > claim, and RLS still applies to them. They are not supported here yet, so > such a token is refused rather than downgraded. Reads the caller's claims from `ctx.jwtClaims`, which `withSupabase` populates (JWKS-verified) — so after `withSupabase` in a pipeline it composes directly: ```ts pipeline([withSupabase({ auth: 'user' }), withPostgresClient()], handler) ``` Standalone (no `withSupabase`), pair it with `withClaims` so `ctx.jwtClaims` is present before it runs. > **Table grants.** Queries run as `authenticated` or `anon`, so those > roles need explicit table privileges (e.g. \`grant select, insert on > > to authenticated`) in addition to RLS policies. A missing grant > fails with `permission denied` (SQLSTATE 42501) before RLS is consulted. > **Runtime note.** `pg` needs raw TCP, so this runs on Node/Deno (including > the Supabase Edge runtime), **not** on Workers-style isolates. ## WithPostgresClientConfig Configuration for withPostgresClient. ## withRequiredClaims The user-mode auth gate: requires a valid user JWT and contributes **non-null** `ctx.jwtClaims`. Verification runs against the project JWKS, the same core `withSupabase` uses for its `user` auth mode. This is the required-caller counterpart to `withClaims`, which contributes claims when a token is present and lets token-less requests proceed as anonymous. A pipeline picks one or the other, "claims required" or "claims if present"; composing both is a compile-time conflict on the `jwtClaims` key. Behavior — every short-circuit uses the standard error payload, with the same code `withSupabase({ auth: 'user' })` returns for an identical request: - No `Authorization: Bearer` token → 401 `MISSING_CREDENTIALS`. The handler never runs. - An `sb_*` API key in that position → 401 `UNUSABLE_CREDENTIAL`: a credential arrived, just not a user JWT. - Token present but invalid → 401 `INVALID_JWT`, naming the specific reason. - Token present but no JWKS configured → 500 `JWKS_NOT_CONFIGURED`; verification is not optional and there is no decode-only mode. Because the contribution is non-null, gated handlers read `ctx.jwtClaims` directly, with no `?.sub ?? 'anon'` fallbacks. Downstream entries declaring a `jwtClaims` prerequisite, such as `withPostgresClient`, compose with no further verification. The 401 and 500 short-circuits carry no CORS headers, and a bare pipeline answers no `OPTIONS` preflight. For browser callers, compose `withCors` (`@supabase/middleware/cors`) ahead of the gate: it answers preflight before the gate runs and stamps `Access-Control-*` headers on the short-circuit responses. After `withSupabase` in a pipeline the context already carries verified `jwtClaims`, so this gate is unnecessary there and placing it after `withSupabase` is a compile-time conflict. Use `withSupabase({ auth: 'user' })` to gate that path. ### Examples #### Gated standalone pipeline ```ts import { pipeline } from '@supabase/middleware' import { withRequiredClaims } from '@supabase/server/middleware/required-claims' import { withPostgresClient } from '@supabase/server/middleware/postgres' export default { fetch: pipeline([withRequiredClaims(), withPostgresClient()], async (req, ctx) => { const rows = await ctx.postgres.query`select id, title from posts` return Response.json({ rows, caller: ctx.jwtClaims.sub }) }), } ``` ## WithRequiredClaimsConfig Configuration for withRequiredClaims. ## withSupabase Wraps a request handler with Supabase auth, client creation, and CORS handling. Built for the Web API `Request`/`Response` standard that all modern runtimes implement natively. Handles CORS preflight, credential verification, context creation, and error responses. Your handler only runs on successful auth. ### Examples #### Basic usage ```ts import { withSupabase } from '@supabase/server' export default { fetch: withSupabase({ auth: 'user' }, async (req, ctx) => { const { data } = await ctx.supabase.rpc('get_my_profile') return Response.json(data) }), } ``` ## withSupabaseAdminClient Contributes `ctx.supabaseAdmin` — an admin Supabase client that bypasses Row-Level Security, authenticated with a secret key. This is the same middleware `withSupabase` composes internally to build its context. The client is constructed on the first property access of `ctx.supabaseAdmin` and memoized for the request. A handler that never accesses it requires no secret key. ### Examples #### Standalone pipeline ```ts import { pipeline } from '@supabase/middleware' import { withSupabaseAdminClient } from '@supabase/server/middleware/admin-client' export default { fetch: pipeline([withSupabaseAdminClient()], async (req, ctx) => { await ctx.supabaseAdmin.from('audit_log').insert({ action: 'ping' }) return Response.json({ ok: true }) }), } ``` ## WithSupabaseAdminClientConfig Configuration for withSupabaseAdminClient — the same environment and client options `createAdminClient` accepts, minus the per-request auth identity (which is read from the upstream context). ## withSupabaseClient Contributes `ctx.supabase` — a Supabase client scoped to the caller's identity, so Row-Level Security policies apply. This is the same middleware `withSupabase` composes internally to build its context. Standalone, the caller's Bearer token (when present) is attached unverified — PostgREST verifies it on every query. Compose middleware/claims!withClaims upstream when the pipeline itself needs verified claims. ### Examples #### Standalone pipeline ```ts import { pipeline } from '@supabase/middleware' import { withSupabaseClient } from '@supabase/server/middleware/client' export default { fetch: pipeline([withSupabaseClient()], async (req, ctx) => { const { data } = await ctx.supabase.from('posts').select('id, title') return Response.json(data) }), } ``` ## WithSupabaseClientConfig Configuration for withSupabaseClient — the same environment and client options `createContextClient` accepts, minus the per-request auth identity (which is read from the request and the upstream context). ## Primitives ## createAdminClient Creates an admin Supabase client that bypasses Row-Level Security. Uses a secret key for authentication, giving full access to all data. Stateless — one client per request. ## Which key is used With `auth.keyName` set, that named key from `SUPABASE_SECRET_KEYS` is used — and it throws if the key doesn't exist. With `keyName` omitted, the `default` key is used, falling back to the first key in the set when no `default` exists. Note this differs from the `"secret"` auth mode, which matches the `default` key only and never falls back — see index.AuthModeWithKey. ### Examples #### Basic usage ```ts // Uses the `default` secret key (or the first key if no `default` exists) const supabaseAdmin = createAdminClient() const { data } = await supabaseAdmin.from('audit_log').insert({ action: 'user_login' }) ``` #### Specific named key ```ts const supabaseAdmin = createAdminClient({ auth: { keyName: 'internal' } }) ``` ## createContextClient Creates a Supabase client scoped to the caller's context. Configured with a publishable key and (optionally) the caller's JWT, so Row-Level Security policies apply. Stateless — one client per request. ## Which key is used With `auth.keyName` set, that named key from `SUPABASE_PUBLISHABLE_KEYS` is used — and it throws if the key doesn't exist. With `keyName` omitted, the `default` key is used, falling back to the first key in the set when no `default` exists. Note this differs from the `"publishable"` auth mode, which matches the `default` key only and never falls back — see index.AuthModeWithKey. ### Examples #### With verified auth ```ts const { data: auth } = await verifyAuth(request, { auth: 'user' }) const supabase = createContextClient({ auth: { token: auth.token, keyName: auth.keyName }, }) const { data } = await supabase.rpc('get_my_items') ``` ## extractCredentials Extracts authentication credentials from an incoming HTTP request. Reads two headers: - `Authorization: Bearer ` → extracted as `token` - `apikey: ` → extracted as `apikey` This is a pure extraction step — no validation or verification is performed. Pass the result to verifyCredentials to validate against allowed auth modes. ### Examples #### Basic usage ```ts import { extractCredentials } from '@supabase/server/core' const creds = extractCredentials(request) console.log(creds.token) // "eyJhbGci..." or null console.log(creds.apikey) // "sb-abc123-publishable-..." or null ``` ## resolveEnv Resolves Supabase environment configuration from runtime environment variables. Reads `SUPABASE_URL`, keys (`SUPABASE_PUBLISHABLE_KEYS` / `SUPABASE_SECRET_KEYS`), and the JWKS source (`SUPABASE_JWKS` for inline keys, `SUPABASE_JWKS_URL` for a remote endpoint, or the well-known endpoint derived from `SUPABASE_PUBLIC_URL` / the resolved `url`). Works across Deno, Node.js, and Bun. For Cloudflare Workers, use `overrides` or enable node-compat. ### Examples #### Reading and overriding env vars ```ts const { data: env, error } = resolveEnv() if (error) throw error // Override for tests const { data: env } = resolveEnv({ url: 'http://localhost:54321' }) ``` ## verifyAuth Extracts credentials from a request and verifies them in a single step. This is a convenience function that combines extractCredentials and verifyCredentials. Use it when you want the full auth flow without needing to inspect the raw credentials. ### Examples #### User auth ```ts import { verifyAuth } from '@supabase/server/core' const { data: auth, error } = await verifyAuth(request, { auth: 'user', }) if (error) { return Response.json({ message: error.message }, { status: error.status }) } console.log(auth.userClaims!.id) // "d0f1a2b3-..." ``` ## VerifyAuthOptions Options for verifyAuth. ## verifyCredentials Verifies pre-extracted credentials against one or more allowed auth modes. Tries each mode in order — first match wins. A mode is only tried when its credential is present; a JWT that is present but fails verification short-circuits the chain with InvalidJwtError instead of falling through to the next mode. Use verifyAuth to extract and verify in a single call. When every mode falls through, the returned error names the actual cause rather than a generic failure — MissingCredentialsError when the request carried nothing, InvalidApiKeyError when an `apikey` matched no configured key, or a `500` (JwksNotConfiguredError, NoKeysConfiguredError) when the server is configured such that no request could ever have succeeded. Every error carries `hint`, `docs`, and non-sensitive `details`. A misconfiguration `500` is only reported once nothing has matched, so an allowed mode that does match the request's credentials still wins — e.g. `['user', 'secret']` with no JWKS but a valid apikey authenticates as `secret`. `sb_*` values in the Authorization slot are API keys, not user tokens, and stay a caller error. ### Examples #### Multiple auth modes ```ts const credentials = extractCredentials(request) const { data: auth, error } = await verifyCredentials(credentials, { auth: ['user', 'publishable'], }) if (error) { console.error(error.code, error.message, error.hint) return Response.json(error.toJSON(), { status: error.status }) } ``` ## VerifyCredentialsOptions Options for verifyCredentials. ## Adapters ## SupabaseError Wraps an AuthError as an Elysia-compatible error. Discriminate in `onError` via `code === 'SupabaseError'`. The original `AuthError` is available as the typed `.cause`. **Deprecated.** Removed with the Elysia adapter on December 1, 2026. The Elysia bridge from the framework integration guide answers auth failures with a `Response` instead: ## withSupabase Elysia plugin that creates a SupabaseContext and makes it available in route handlers. Skips if a previous plugin already set the context, enabling route-level overrides. Throws a `SupabaseError` on auth failure. `.status` is on the error directly; the original `AuthError` is available as the typed `.cause`. Discriminate in `onError` via `code === 'SupabaseError'`. **Deprecated.** Removed on December 1, 2026. Use the Elysia bridge from the framework integration guide instead: ### Examples #### App-wide auth via .use() ```ts import { Elysia } from 'elysia' import { withSupabase } from '@supabase/server/adapters/elysia' const app = new Elysia() .use(withSupabase({ auth: 'user' })) .get('/games', async ({ supabaseContext }) => { const { data } = await supabaseContext.supabase.from('favorite_games').select() return data }) app.listen(3000) ``` #### Per-route auth via scoped .use() ```ts import { Elysia } from 'elysia' import { withSupabase } from '@supabase/server/adapters/elysia' const app = new Elysia() .get('/health', () => ({ status: 'ok' })) .group('/api', (app) => app .use(withSupabase({ auth: 'user' })) .get('/profile', async ({ supabaseContext }) => { return supabaseContext.userClaims }) ) app.listen(3000) ``` ## Errors ## ApiKeyFormat How an `apikey` header value was classified by its public prefix. Used in diagnostics so a format mismatch can be reported without echoing the key. ## AuthError Thrown when authentication or authorization fails. Carries an HTTP `status` code suitable for returning directly in a response (`401` when the request's credentials are at fault, `500` when the server is misconfigured and could not have authenticated anyone). ### Examples #### Catching an AuthError ```ts import { createSupabaseContext } from '@supabase/server' const { data: ctx, error } = await createSupabaseContext(request, { auth: 'user' }) if (error) { // error is an AuthError — `toJSON()` includes source, code, message, hint, docs, details return Response.json(error.toJSON(), { status: error.status }) } ``` ## AuthFailureContext Everything the auth pipeline knows about a failed attempt, minus anything secret. Used to build messages, hints, and `details`. ## AuthGenericError Generic authentication error code. ## CreateSupabaseClientError Failed to create a Supabase client after auth succeeded. ## EnvError Thrown when a required environment variable is missing or malformed. Always has `status: 500` — environment errors are server-side configuration issues. ### Examples #### Catching an EnvError ```ts import { EnvError } from '@supabase/server' try { const client = createAdminClient() } catch (e) { if (e instanceof EnvError) { console.error(`[${e.code}] ${e.message}\n${e.hint}`) // → "[MISSING_SUPABASE_URL] [@supabase/server] SUPABASE_URL is required but not set." // "Set SUPABASE_URL to your project URL (https://.supabase.co), …" } } ``` ## EnvGenericError Generic environment error code. ## ErrorCodeHeader Response header withSupabase sets on every error response, carrying the error SupabaseServerError.code. ## ErrorPayload Serializable form of a SupabaseServerError — the exact JSON body withSupabase returns on failure. ## Errors Factory map for all error types. Keyed by error code constant, each entry returns a pre-configured EnvError, AuthError, or PostgresPoolError complete with `hint`, `docs`, and `details`. ### Examples #### Throwing typed errors ```ts throw Errors[MissingSupabaseURLError]() throw Errors[MissingPublishableKeyError]('mobile', ['default', 'web']) ``` ## ErrorSource Package identifier stamped on every error this library produces. Present as `source` on each error instance, as the `source` field of the JSON body withSupabase returns, and as a `[@supabase/server]` prefix on every `message` — so an error is traceable back here from a log line, a response body, or a response header alone. ## InvalidApiKeyError An `apikey` header was present but matched none of the keys configured for the accepted auth modes. ## InvalidCredentialsError Generic credential failure. Retained as the fallback code for cases that don't match a more specific one. Prior to v1.5 this was the only code withSupabase ever returned for a failed request. Prefer matching on the specific codes — MissingCredentialsError, InvalidApiKeyError, InvalidJwtError — which now cover nearly every real failure. ## InvalidJwtError A JWT was present in the `Authorization` header but failed verification. `details.jwt` and the message carry the specific reason (expired, bad signature, unknown `kid`, malformed token or claim, no `sub`, mismatched or missing `aud` / `iss`, `nbf` in the future). ## JwksFetchFailedError The remote JWKS endpoint could not be fetched or returned something unusable. Server-side / upstream failure (`status: 500`), not a bad request. ## JwksNotConfiguredError Auth mode `"user"` was requested and a JWT was supplied, but no JWKS is configured — so the token cannot be verified. Server misconfiguration (`status: 500`), not a bad request. ## MinimalErrorPayload Reduced error body produced when `errors: { detailed: false }` is set. `message` keeps its `[@supabase/server]` prefix, so the error is still traceable back here even without the `source` field. ## MissingAuthorizationServerError `withOAuthProtectedResource` has no `authorizationServer` and cannot derive one. ## MissingConnectionStringError No Postgres connection string is configured, so the `withPostgresClient` / `withPostgresAdminClient` middleware cannot connect. ## MissingCredentialsError The request carried no credentials at all — neither an `apikey` header nor an `Authorization: Bearer` token — and no accepted auth mode allows that. ## MissingDefaultPublishableKeyError No default publishable key found. ## MissingDefaultSecretKeyError No default secret key found. ## MissingPublishableKeyError Named publishable key not found in `SUPABASE_PUBLISHABLE_KEYS`. ## MissingResourceServerError `withOAuthProtectedResource` has no `resourceServer` and cannot derive one. ## MissingSecretKeyError Named secret key not found in `SUPABASE_SECRET_KEYS`. ## MissingSupabaseURLError `SUPABASE_URL` is not set. ## NoKeysConfiguredError An `"publishable"` or `"secret"` auth mode was requested but no keys of that kind are configured, so the mode could never match. Server misconfiguration (`status: 500`). ## PostgresConnectPausedError A connection attempt failed and the pool is pausing new attempts, so a query that needed a new connection was refused without trying. `cause` is the failure and `details.retryAfterMs` the time left in the pause. ## PostgresPoolBusyError Every pooled connection stayed busy for the whole checkout wait, so the query could not get one. `details.max` is the pool size and `details.waitedMs` the wait. ## PostgresPoolError Thrown when the Postgres pool cannot hand a query a connection: every pooled connection stayed busy for the whole checkout wait, or new connection attempts are paused after one failed. Always has `status: 503`. Both conditions are transient, so the request can be retried. `details.retryAfterMs` gives the time left when a pause is running. `ctx.postgres` and `ctx.postgresAdmin` throw this from the query call. The middleware do not turn it into a response; the handler or the host's error handler decides how the request answers. ### Examples #### Answering 503 with a Retry-After header ```ts import { PostgresPoolError } from '@supabase/server' try { return Response.json(await ctx.postgres.query`select * from notes`) } catch (e) { if (e instanceof PostgresPoolError) { const retryAfterMs = Number(e.details?.retryAfterMs ?? 1000) return Response.json(e.toJSON(), { status: e.status, headers: { 'Retry-After': String(Math.ceil(retryAfterMs / 1000)) }, }) } throw e } ``` ## ReceivedCredentials Non-sensitive summary of the credentials a request carried. ## SupabaseServerError Base class for every error `@supabase/server` produces. Catch this to handle anything originating from this library, regardless of whether it's an EnvError or an AuthError: ### Examples #### Catching any library error ```ts import { SupabaseServerError } from '@supabase/server' try { const supabase = createAdminClient() } catch (e) { if (e instanceof SupabaseServerError) { console.error(e.code, e.message, e.hint, e.docs) return Response.json(e.toJSON(), { status: e.status }) } throw e } ``` ## SupabaseServerErrorOptions Optional diagnostics attached to a SupabaseServerError. ## UnsupportedRoleError The caller's verified `role` claim names a Postgres role the middleware will not assume — either one that would bypass RLS, or one it does not support yet. Returned by `withPostgresClient` instead of silently running the query as `anon`. ## UnusableCredentialError A credential *did* arrive, but not one any accepted auth mode can use — an `sb_*` API key in the `Authorization` header, or a header this library cannot read a bearer token out of (wrong scheme, wrong casing, bare value, empty token). Distinct from MissingCredentialsError on purpose. The two partition the "nothing authenticated" space exactly — nothing arrived vs. something arrived that could not be used — so the code stays true on its own when `errors: { detailed: false }` strips `hint` and `details`. ## Types ## Allow **Deprecated.** Use AuthMode instead. Will be removed in a future major release. ## AllowWithKey **Deprecated.** Use AuthModeWithKey instead. Will be removed in a future major release. ## AuthConfig Accepted shape of the `auth` option: one mode, or an ordered list of modes tried left to right. `"none"` accepts every request, so it only carries meaning as the last entry of a list — the modes before it are the ones that can produce an identity, and anything after it is unreachable. On its own in a list (`["none"]`) it says nothing that a bare `auth: 'none'` doesn't. So the type accepts `"none"` alone or in final position behind at least one credentialed mode, and nowhere else. A single mode needs no wrapping array — `"user"` and `["user"]` are the same configuration, and the unwrapped form is the one the union names first, so it is what shows up in editor completions. ### Examples #### Every accepted form ```ts withSupabase({ auth: 'user' }, handler) // one mode withSupabase({ auth: ['secret', 'user'] }, handler) // first match wins withSupabase({ auth: ['user', 'none'] }, handler) // optional user withSupabase({ auth: 'none' }, handler) // no credentials required ``` ## AuthMode Authentication mode that determines what credentials a request must provide. - `"none"` — No credentials required. Every request is accepted. - `"publishable"` — Requires a valid publishable key in the `apikey` header. Matches only the `default` key. - `"secret"` — Requires a valid secret key in the `apikey` header (timing-safe comparison). Matches only the `default` key. - `"user"` — Requires a valid JWT in the `Authorization: Bearer ` header. Bare `"publishable"` / `"secret"` resolve the `default` key from `SUPABASE_PUBLISHABLE_KEYS` / `SUPABASE_SECRET_KEYS`. To target another key or accept any key, see AuthModeWithKey. ### Examples #### Single mode ```ts // Single mode withSupabase({ auth: 'user' }, handler) // Multiple modes — the first match wins. // A mode is tried only when its credential is present; a JWT that is // present but fails verification rejects immediately rather than falling // through to the next mode. withSupabase({ auth: ['user', 'publishable'] }, handler) ``` ## AuthModeWithKey Extended auth mode that supports targeting a specific named key. Use the colon syntax (`"publishable:web_app"`) to require a specific named key from the `SUPABASE_PUBLISHABLE_KEYS` or `SUPABASE_SECRET_KEYS` JSON object. Use `"publishable:*"` or `"secret:*"` to accept any key in the set. The bare form without a colon (`"publishable"` / `"secret"`) matches only the `default` key. ### Examples #### Named key ```ts // Accept only the "mobile" publishable key withSupabase({ auth: 'publishable:mobile' }, handler) // Accept any secret key withSupabase({ auth: 'secret:*' }, handler) // Mix named keys with other modes withSupabase({ auth: ['user', 'publishable:web_app'] }, handler) ``` ## AuthResult Result of credential verification. Contains the resolved auth mode, the verified token (for `"user"` mode), decoded JWT claims, and the matched key name (for `"publishable"` / `"secret"` modes). ## ClientAuth Auth identity for client creation functions. ## CreateAdminClientOptions Options for core.createAdminClient. ## CreateContextClientOptions Options for core.createContextClient. ## CredentialedAuthMode An auth mode that requires a credential — every AuthModeWithKey except `"none"`, keyed forms included. ## Credentials Raw credentials extracted from an incoming HTTP request. Produced by core.extractCredentials from the `Authorization` and `apikey` headers. ## ErrorResponseConfig Controls how much of an error goes in the response body. Accepted as `errors` by withSupabase and by every middleware that answers a request directly: `withClaims`, `withRequiredClaims`, `withPostgresClient`, `withPostgresAdminClient` and `withOAuthProtectedResource`. ### Examples #### Trimming the response ```ts // Full payload: source, code, message, hint, docs, details withSupabase({ auth: 'user' }, handler) // Trimmed: code and message only withSupabase({ auth: 'user', errors: { detailed: false } }, handler) ``` ## JWTClaims Standard JWT claims as defined by RFC 7519, extended with Supabase-specific fields. This is the raw JWT payload — use UserClaims for a normalized, camelCase view. ## OAuthProtectedResourceConfig Configuration for withOAuthProtectedResource. Both options accept a fixed string or a function of the request. Both default to values derived from the request as it arrives through the Supabase Edge Functions proxy, so no configuration is needed there. ## ResourceMetadataOptions Options for resourceMetadataResponse. ## ShortCircuitConfig The option shared by every middleware that answers a request itself: `withSupabase`, `withClaims`, `withRequiredClaims`, `withPostgresClient`, `withPostgresAdminClient` and `withOAuthProtectedResource`. Each applies `errors` to its own responses. ## SupabaseContext The Supabase context created for each authenticated request. Contains pre-configured Supabase clients and the caller's identity. Identical regardless of which layer or adapter produced it. ## SupabaseEnv Resolved Supabase environment configuration. Holds the project URL, API keys, and JWKS needed by every other primitive. Typically resolved automatically from environment variables by core.resolveEnv, but can be passed explicitly via the `env` option. ## UnauthorizedResponseOptions Options for unauthorizedResponse. ## UrlOption A configured URL: either a fixed value, or derived per request. ## UserClaims Normalized, camelCase view of the authenticated user's identity. Derived from JWTClaims. For the full Supabase `User` object (including email confirmation status, providers, etc.), call `supabase.auth.getUser()` using the context client. ## WithSupabaseConfig Configuration for withSupabase and createSupabaseContext. Controls which auth modes are accepted, environment overrides, and CORS behavior. ### Examples #### Basic usage ```ts // Require authenticated users, auto-CORS enabled (default) const config: WithSupabaseConfig = { auth: 'user' } // Accept users or service-to-service calls, custom CORS headers const config: WithSupabaseConfig = { auth: ['user', 'secret'], cors: { 'Access-Control-Allow-Origin': 'https://myapp.com' }, } // No auth required, CORS disabled const config: WithSupabaseConfig = { auth: 'none', cors: 'disabled' } ```