服务器 Client Library
@supabase/serverView on GitHub@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 — for cookie-based sessions in SSR frameworks, use @supabase/ssr instead.
Installing
Install as a package#
Install @supabase/server via your package manager.
npm install @supabase/serverUse via JSR (Deno / Bun)#
@supabase/server is also published to JSR for Deno and Bun environments.
deno add jsr:@supabase/serverOptional 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.
import 'pg'import { withPostgresClient } from '@supabase/server/middleware/postgres'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.
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.
import { withSupabase } from '@supabase/server'export default { fetch: withSupabase({ auth: 'none' }, async () => { return Response.json({ status: 'ok', time: new Date().toISOString() }) }),}[functions.my-function]verify_jwt = falseBuild 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.
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 before you change anything. One step in that migration can open an endpoint up without an error.
Versions
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
supabaseandjwtClaims, and your handler reads them. - Short-circuit. An entry can return a
Response, and your route never runs. That is howwithRequiredClaimsrejects a request. - Response phase. An entry written as
async function*canyield, see the response on the way out, and rewrite it.withCorsneeds 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, 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 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<Database>() threads the generic through, so c.var.supabase is a SupabaseClient<Database>.
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.
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 }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 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<typeof entries> from @supabase/middleware, or wrap that in a small typed accessor of your own.
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 constconst app = new H3()app.use(toH3(entries))app.get('/todos', async (event) => { const { supabase } = event.context as Contributions<typeof entries> 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 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<typeof entries>() 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.
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 constconst app = new Elysia() .use(supabaseCtx<typeof entries>()) .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 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.
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 constconst SupabaseGuard = toNestGuard(entries)@Controller('todos')export class TodosController { @Get() @UseGuards(SupabaseGuard) async list(@Req() req: Contributions<typeof entries>) { 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 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<Contributions<Entries>> is what types context downstream; the generic has no constraint, so leaving it off types the context as undefined. And <const Entries> 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.
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#
Read this before you migrate anything
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.
// 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 <key> gets a bare 401. And auth: 'secret:<name>' also points supabaseAdmin at secretKeys['<name>'], 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.
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.
// Beforeconst { supabase, userClaims } = c.var.supabaseContext// Afterconst supabase = c.var.supabaseconst jwtClaims = c.var.jwtClaimsFor 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.
npm ls @supabase/server # 1.6.0 or laternode --version # 22 or later2. 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.
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.
// auth: 'user', or no configtoHono([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.
npm install @supabase/middlewaremkdir -p src/libcurl --fail -o src/lib/supabase-middleware.ts \ https://raw.githubusercontent.com/supabase/server/main/examples/frameworks/hono/supabase-middleware.ts5. 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.
// Beforeimport { withSupabase } from '@supabase/server/adapters/hono'app.use('*', withSupabase({ auth: 'user' }))// Afterimport { 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.
c.var.supabaseContext.supabase -> c.var.supabasec.var.supabaseContext.supabaseAdmin -> c.var.supabaseAdminc.var.supabaseContext.userClaims.id -> c.var.jwtClaims.subgrep -rn "supabaseContext" src/ # expect no results7. 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.
npx tsc --noEmitBASE=http://localhost:3000# no credentials -> expect 401curl -s -o /dev/null -w "%{http_code}\n" $BASE/your-endpoint# a valid user token -> expect 200curl -s -o /dev/null -w "%{http_code}\n" \ -H "Authorization: Bearer $TOKEN" $BASE/your-endpoint8. 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.
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).
Migrate this project off @supabase/server's framework adapters(@supabase/server/adapters/*) onto @supabase/middleware entries. Work in theorder 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 canopen 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: itcontributes jwtClaims: null and falls through, turning a 401 endpoint into a200 endpoint silently. Never use withClaims() as the replacement forauth: '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 ofthe project; the bridge imports it. Then downloadhttps://raw.githubusercontent.com/supabase/server/main/examples/frameworks/hono/supabase-middleware.tsand 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 passthat const to every @UseGuards().Import the client entries from '@supabase/server/middleware/client' and'@supabase/server/middleware/admin-client'. Only includewithSupabaseAdminClient() 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.supabaseAdminApply 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 JWTpayload, 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_metadataRewrite each read individually. Do NOT do a blanket rename; in plainJavaScript 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 instep 1 as rejecting, confirm an unauthenticated request still returns 401 andan authenticated one still returns 200. A passing typecheck does not provethis; 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 listevery file you changed and anything you could not migrate.createSupabaseContext
createSupabaseContext(request, options?)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.
Parameters
- requestRequest
The incoming HTTP request.
- optionsOptionalWithSupabaseConfig
Auth modes, environment overrides. The
corsoption is ignored here.
Return Type
- Option 1object
- Option 2object
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
fromSupabaseUrl(supabaseUrl)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.
Parameters
- supabaseUrlstring
The project URL, e.g.
https://<ref>.supabase.co(the same value passed tocreateClient()).
Return Type
import { withOAuthProtectedResource, fromSupabaseUrl } from '@supabase/server'withOAuthProtectedResource( { resourceServer: (req) => new URL(req.url).origin + '/api/mcp', authorizationServer: fromSupabaseUrl('https://abc123.supabase.co'), }, handler,)ident
ident(name)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.
Parameters
- namestring
The identifier to quote.
Return Type
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
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
PostgresPoolOptions(max?, checkoutTimeoutMs?)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.
Parameters
- maxOptionalnumber
Connections this process opens at most on this connection string. A positive integer. The pooler team recommends one to four for serverless deployments.
- checkoutTimeoutMsOptionalnumber
Milliseconds a query waits for a free connection before failing with
POSTGRES_POOL_BUSY, and how long a new connection may take to come up. A positive number. Keep it well under the pooler's own 60 second checkout timeout so the failure surfaces here, with a message that says what happened.
RequestClaims
RequestClaims(role?)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.
Parameters
- roleOptionalstring
resourceMetadataResponse
resourceMetadataResponse(req, options?)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.
Parameters
- reqRequest
- optionsOptionalResourceMetadataOptions
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: Bearertoken (or ansb_*API key in that position) → contributesnull; the request proceeds as anonymous. - Token present but invalid → short-circuits with a 401 JSON response (
{ message, code }, matchingwithSupabase'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.
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
WithClaimsConfig(jwks?, audience?, issuer?, errors?)Configuration for withClaims.
Parameters
- jwksOptionalOne of the following options
JWKS source used to verify tokens: an inline key set or a remote JWKS URL. Defaults to
SUPABASE_JWKS(inline JSON) orSUPABASE_JWKS_URL(https endpoint) from the environment.- Option 1JSONWebKeySet
- Option 2URL
- audienceOptionalOne of the following options
Accepted
audclaim value(s). When set, a token withoutaudis rejected, and a token whoseaudis an array passes when any entry is accepted.- Option 1string
- Option 2Array<string>
- issuerOptionalOne of the following options
Accepted
issclaim value(s). When set, a token withoutissis rejected. Supabase Auth issueshttps://<project-ref>.supabase.co/auth/v1, whichfromSupabaseUrl(url)builds from a project URL.- Option 1string
- Option 2Array<string>
- errorsOptionalErrorResponseConfig
How much of an error to include in a short-circuit response body.
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 theOPTIONSpreflight, so browser-based clients can read it) - Enriches a
401from the inner handler withWWW-Authenticate: Bearer resource_metadata="...", unless the handler already set aWWW-Authenticateheader (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
withSupabasereturns for its own configuration failures (500,x-supabase-server-error); a throw from a configuredresourceServerorauthorizationServerfunction 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.
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) }), ),)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':
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:
pipeline([withSupabase({ auth: 'user' }), withPostgresClient(), withPostgresAdminClient()], handler)Authorization is yours now. RLS is not consulted, so any per-user scoping has to be a
whereclause you write. Reach forwithPostgresClientunless you specifically need to cross user boundaries.
Runtime note.
pgneeds raw TCP, so this runs on Node/Deno (including the Supabase Edge runtime), not on Workers-style isolates.
WithPostgresAdminClientConfig
WithPostgresAdminClientConfig(connectionString?, pool?, errors?)Configuration for withPostgresAdminClient.
Parameters
- connectionStringOptionalstring
Defaults to
getEnv('SUPABASE_DB_URL')(from@supabase/middleware). - poolOptionalPostgresPoolOptions
Pool size and checkout timeout for this connection string. Checked when the middleware is built; an invalid value throws a
RangeErrorthen. Entries whose options resolve to the same values share one pool. - errorsOptionalErrorResponseConfig
How much of an error to include in a short-circuit response body.
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:
begin;select set_config('request.jwt.claims', $claims, true); -- auth.uid() resolvesset local role "authenticated"; -- RLS now enforces<your query>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
roleclaim, 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:
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
authenticatedoranon, so those roles need explicit table privileges (e.g.grant select, insert on <table> to authenticated) in addition to RLS policies. A missing grant fails withpermission denied(SQLSTATE 42501) before RLS is consulted.
Runtime note.
pgneeds raw TCP, so this runs on Node/Deno (including the Supabase Edge runtime), not on Workers-style isolates.
WithPostgresClientConfig
WithPostgresClientConfig(connectionString?, pool?, errors?)Configuration for withPostgresClient.
Parameters
- connectionStringOptionalstring
Defaults to
getEnv('SUPABASE_DB_URL')(from@supabase/middleware). - poolOptionalPostgresPoolOptions
Pool size and checkout timeout for this connection string. Checked when the middleware is built; an invalid value throws a
RangeErrorthen. Entries whose options resolve to the same values share one pool. - errorsOptionalErrorResponseConfig
How much of an error to include in a short-circuit response body.
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: Bearertoken → 401MISSING_CREDENTIALS. The handler never runs. - An
sb_*API key in that position → 401UNUSABLE_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.
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
WithRequiredClaimsConfig(jwks?, audience?, issuer?, errors?)Configuration for withRequiredClaims.
Parameters
- jwksOptionalOne of the following options
JWKS source used to verify tokens: an inline key set or a remote JWKS URL. Defaults to
SUPABASE_JWKS(inline JSON) orSUPABASE_JWKS_URL(https endpoint) from the environment.- Option 1JSONWebKeySet
- Option 2URL
- audienceOptionalOne of the following options
Accepted
audclaim value(s). When set, a token withoutaudis rejected, and a token whoseaudis an array passes when any entry is accepted.- Option 1string
- Option 2Array<string>
- issuerOptionalOne of the following options
Accepted
issclaim value(s). When set, a token withoutissis rejected. Supabase Auth issueshttps://<project-ref>.supabase.co/auth/v1, whichfromSupabaseUrl(url)builds from a project URL.- Option 1string
- Option 2Array<string>
- errorsOptionalErrorResponseConfig
How much of an error to include in a short-circuit response body.
withSupabase
withSupabase(config, handler)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.
Parameters
- configWithSupabaseConfig
Auth modes, CORS, and environment overrides. See WithSupabaseConfig.
- handlerfunction
Receives the
Requestand a fully-initialized SupabaseContext.
Return Type
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
withSupabaseAdminClient(config?)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.
Parameters
- configOptionalWithSupabaseAdminClientConfig
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
withSupabaseClient(config?)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.
Parameters
- configOptionalWithSupabaseClientConfig
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).
createAdminClient
createAdminClient(options?)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.
Parameters
- optionsOptionalCreateAdminClientOptions
// 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' })createContextClient
createContextClient(options?)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.
Parameters
- optionsOptionalCreateContextClientOptions
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
extractCredentials(request)Extracts authentication credentials from an incoming HTTP request.
Reads two headers:
Authorization: Bearer <token>→ extracted astokenapikey: <key>→ extracted asapikey
This is a pure extraction step — no validation or verification is performed. Pass the result to verifyCredentials to validate against allowed auth modes.
Parameters
- requestRequest
The incoming HTTP request.
Return Type
import { extractCredentials } from '@supabase/server/core'const creds = extractCredentials(request)console.log(creds.token) // "eyJhbGci..." or nullconsole.log(creds.apikey) // "sb-abc123-publishable-..." or nullresolveEnv
resolveEnv(overrides?)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.
Parameters
- overridesOptionalPartial
Partial values that take precedence over env vars.
Return Type
- Option 1object
- Option 2object
const { data: env, error } = resolveEnv()if (error) throw error// Override for testsconst { data: env } = resolveEnv({ url: 'http://localhost:54321' })verifyAuth
verifyAuth(request, options)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.
Parameters
- requestRequest
The incoming HTTP request.
- optionsVerifyAuthOptions
Auth modes to accept and optional environment overrides.
Return Type
- Option 1object
- Option 2object
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
VerifyAuthOptions(auth?, allow?, audience?, issuer?, env?)Options for verifyAuth.
Parameters
- authOptionalOne of the following options
Auth mode(s) to try. Modes are attempted in order — the first match wins.
- Option 1One of the following options
- Option 1One of the following options
- Option 1"none"
- Option 2"publishable"
- Option 3"secret"
- Option 4"user"
- Option 2
- Option 3
- Option 2Array<One of the following options>
- allowOptionalOne of the following options
- Option 1One of the following options
- Option 1One of the following options
- Option 1"none"
- Option 2"publishable"
- Option 3"secret"
- Option 4"user"
- Option 2
- Option 3
- Option 2Array<One of the following options>
- audienceOptionalOne of the following options
Accepted
audclaim value(s) forusermode. When set, a token withoutaudis rejected, and a token whoseaudis an array passes when any entry is accepted.- Option 1string
- Option 2Array<string>
- issuerOptionalOne of the following options
Accepted
issclaim value(s) forusermode. When set, a token withoutissis rejected. Supabase Auth issueshttps://<project-ref>.supabase.co/auth/v1, whichfromSupabaseUrl(url)builds from a project URL.- Option 1string
- Option 2Array<string>
- envOptionalPartial
Optional environment overrides (passed through to resolveEnv).
verifyCredentials
verifyCredentials(credentials, options)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.
Parameters
- credentialsCredentials
The credentials to verify (from extractCredentials).
- optionsVerifyCredentialsOptions
Allowed auth modes and optional env overrides.
Return Type
- Option 1object
- Option 2object
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
VerifyCredentialsOptions(auth?, allow?, audience?, issuer?, env?)Options for verifyCredentials.
Parameters
- authOptionalOne of the following options
Auth mode(s) to try. Modes are attempted in order — the first match wins.
- Option 1One of the following options
- Option 1One of the following options
- Option 1"none"
- Option 2"publishable"
- Option 3"secret"
- Option 4"user"
- Option 2
- Option 3
- Option 2Array<One of the following options>
- allowOptionalOne of the following options
- Option 1One of the following options
- Option 1One of the following options
- Option 1"none"
- Option 2"publishable"
- Option 3"secret"
- Option 4"user"
- Option 2
- Option 3
- Option 2Array<One of the following options>
- audienceOptionalOne of the following options
Accepted
audclaim value(s) forusermode. When set, a token withoutaudis rejected, and a token whoseaudis an array passes when any entry is accepted.- Option 1string
- Option 2Array<string>
- issuerOptionalOne of the following options
Accepted
issclaim value(s) forusermode. When set, a token withoutissis rejected. Supabase Auth issueshttps://<project-ref>.supabase.co/auth/v1, whichfromSupabaseUrl(url)builds from a project URL.- Option 1string
- Option 2Array<string>
- envOptionalPartial
Optional environment overrides (passed through to resolveEnv).
SupabaseError
SupabaseError(status, cause)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: https://supabase.com/docs/reference/server/frameworks
Parameters
- statusRequirednumber
- causeRequiredAuthError
withSupabase
withSupabase(config?)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: https://supabase.com/docs/reference/server/frameworks
Parameters
- configOptionalOmit
Auth modes and optional environment overrides. CORS is excluded — use Elysia's CORS utilities.
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)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
AuthError(source, code, hint?, docs, details?, status)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).
Parameters
- sourceRequired"@supabase/server"
Always
"@supabase/server". - codeRequiredstring
Machine-readable error code.
- hintOptionalstring
Actionable next step, when one applies.
- docsRequiredstring
Documentation URL for this error's
code. - detailsOptionalRecord<string, unknown>
Structured, non-sensitive diagnostics — accepted auth modes, which credential headers were present, configured key names, JWT header fields. Never key values or token payloads.
- statusRequirednumber
HTTP status code.
401— The request's credentials are at fault (MissingCredentialsError, InvalidApiKeyError, InvalidJwtError).500— The server is misconfigured (JwksNotConfiguredError, NoKeysConfiguredError, JwksFetchFailedError, CreateSupabaseClientError).
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
AuthFailureContext(authModes, received, configuredKeyNames?, matchedKey?)Everything the auth pipeline knows about a failed attempt, minus anything secret. Used to build messages, hints, and details.
Parameters
- authModesRequiredArray<string>
Auth modes that were attempted, in the order they were tried.
- receivedRequiredReceivedCredentials
Which credential headers the request carried.
- configuredKeyNamesOptionalRecord<string, Array<string>>
Names (never values) of the keys configured for the attempted modes. Omitted for modes that don't use API keys.
- matchedKeyOptionalobject
Set when the rejected
apikeyis a configured key ofkindheld undername, andmodeis the attempted mode that accepts a different name. Carries the name only, never the value.
AuthGenericError
Generic authentication error code.
CreateSupabaseClientError
Failed to create a Supabase client after auth succeeded.
EnvError
EnvError(source, code, hint?, docs, details?, status)Thrown when a required environment variable is missing or malformed.
Always has status: 500 — environment errors are server-side configuration issues.
Parameters
- sourceRequired"@supabase/server"
Always
"@supabase/server". - codeRequiredstring
Machine-readable error code.
- hintOptionalstring
Actionable next step, when one applies.
- docsRequiredstring
Documentation URL for this error's
code. - detailsOptionalRecord<string, unknown>
Structured, non-sensitive diagnostics — accepted auth modes, which credential headers were present, configured key names, JWT header fields. Never key values or token payloads.
- statusRequired"500"
Always
500— environment errors are server-side issues.
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://<project-ref>.supabase.co), …" }}EnvGenericError
Generic environment error code.
ErrorCodeHeader
Response header withSupabase sets on every error response, carrying the error SupabaseServerError.code.
ErrorPayload
ErrorPayload(source, code, message, hint?, docs, details?)Serializable form of a SupabaseServerError — the exact JSON body withSupabase returns on failure.
Parameters
- sourceRequired"@supabase/server"
Always
"@supabase/server". Identifies which library produced the error. - codeRequiredstring
Machine-readable error code, also sent as the
x-supabase-server-errorheader. - messageRequiredstring
Human-readable description, prefixed with
[@supabase/server]. - hintOptionalstring
Actionable next step. Omitted when there isn't a useful one.
- docsRequiredstring
Documentation URL for this specific code.
- detailsOptionalRecord<string, unknown>
Structured, non-sensitive diagnostics. Omitted when there are none.
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.
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.
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
PostgresPoolError(source, code, hint?, docs, details?, status)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.
Parameters
- sourceRequired"@supabase/server"
Always
"@supabase/server". - codeRequiredstring
Machine-readable error code.
- hintOptionalstring
Actionable next step, when one applies.
- docsRequiredstring
Documentation URL for this error's
code. - detailsOptionalRecord<string, unknown>
Structured, non-sensitive diagnostics — accepted auth modes, which credential headers were present, configured key names, JWT header fields. Never key values or token payloads.
- statusRequired"503"
Always
503. Nothing about the request is wrong; the pool has no connection for it yet.
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
ReceivedCredentials(authorization, apikey)Non-sensitive summary of the credentials a request carried.
Parameters
- authorizationRequiredOne of the following options
What the
Authorizationheader carried.'bearer'— a bearer token that looks like a JWT'api-key'— ansb_*API key, which the Supabase SDK forwards here alongside theapikeyheader; not a user token'non-bearer-scheme'— present but unusable (wrong scheme, wrong casing, bare value, empty token)'absent'— no header
- Option 1"absent"
- Option 2"bearer"
- Option 3"api-key"
- Option 4"non-bearer-scheme"
- apikeyRequiredOne of the following options
The
apikeyheader classified by prefix. Never the value itself.- Option 1"absent"
- Option 2"publishable"
- Option 3"secret"
- Option 4"legacy-jwt"
- Option 5"unrecognized"
SupabaseServerError
SupabaseServerError(source, status, code, hint?, docs, details?)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:
Parameters
- sourceRequired"@supabase/server"
Always
"@supabase/server". - statusRequirednumber
HTTP status code appropriate for this error.
- codeRequiredstring
Machine-readable error code.
- hintOptionalstring
Actionable next step, when one applies.
- docsRequiredstring
Documentation URL for this error's
code. - detailsOptionalRecord<string, unknown>
Structured, non-sensitive diagnostics — accepted auth modes, which credential headers were present, configured key names, JWT header fields. Never key values or token payloads.
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
SupabaseServerErrorOptions(hint?, details?, docs?, cause?)Optional diagnostics attached to a SupabaseServerError.
Parameters
- hintOptionalstring
Actionable next step for whoever has to fix this.
- detailsOptionalRecord<string, unknown>
Structured, non-sensitive diagnostics. Never contains key values, token payloads, or any other secret material.
- docsOptionalstring
Override the generated documentation URL.
- causeOptionalunknown
Underlying error, when this one wraps another.
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.
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.
withSupabase({ auth: 'user' }, handler) // one modewithSupabase({ auth: ['secret', 'user'] }, handler) // first match winswithSupabase({ auth: ['user', 'none'] }, handler) // optional userwithSupabase({ auth: 'none' }, handler) // no credentials requiredAuthMode
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 theapikeyheader. Matches only thedefaultkey."secret"— Requires a valid secret key in theapikeyheader (timing-safe comparison). Matches only thedefaultkey."user"— Requires a valid JWT in theAuthorization: Bearer <token>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.
// Single modewithSupabase({ 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.
// Accept only the "mobile" publishable keywithSupabase({ auth: 'publishable:mobile' }, handler)// Accept any secret keywithSupabase({ auth: 'secret:*' }, handler)// Mix named keys with other modeswithSupabase({ auth: ['user', 'publishable:web_app'] }, handler)AuthResult
AuthResult(authMode, token, userClaims, jwtClaims, keyName?)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).
Parameters
- authModeRequiredOne of the following options
The auth mode that was successfully matched.
- Option 1"none"
- Option 2"publishable"
- Option 3"secret"
- Option 4"user"
- tokenRequiredOne of the following options
The verified JWT, or
nullfor non-user auth modes.- Option 1string
- Option 2null
- userClaimsRequiredOne of the following options
Normalized user identity derived from the JWT, or
nullwhen no JWT is present.- Option 1UserClaims
- Option 2null
- jwtClaimsRequiredOne of the following options
Raw JWT payload, or
nullwhen no JWT is present.- Option 1JWTClaims
- Option 2null
- keyNameOptionalOne of the following options
Name of the matched key (e.g.
"default","mobile"), ornullfor"user"/"none"modes.- Option 1string
- Option 2null
ClientAuth
ClientAuth(token?, keyName?)Auth identity for client creation functions.
Parameters
- tokenOptionalOne of the following options
The caller's JWT, or
nullfor anonymous access.- Option 1string
- Option 2null
- keyNameOptionalOne of the following options
Name of the API key to use. Falls back to
"default", then first available.- Option 1string
- Option 2null
CreateAdminClientOptions
CreateAdminClientOptions(auth?, env?, supabaseOptions?)Options for core.createAdminClient.
Parameters
- authOptional
Auth identity — key name from the verified request.
- envOptionalPartial
Override auto-detected environment variables.
- supabaseOptionsOptionalSupabaseClientOptions
Options forwarded to
createClient().accessTokenis stripped; auth settings are force-overwritten.
CreateContextClientOptions
CreateContextClientOptions(auth?, env?, supabaseOptions?)Options for core.createContextClient.
Parameters
- authOptionalClientAuth
Auth identity — token and key name from the verified request.
- envOptionalPartial
Override auto-detected environment variables.
- supabaseOptionsOptionalSupabaseClientOptions
Options forwarded to
createClient().accessTokenis stripped; auth settings are force-overwritten.
CredentialedAuthMode
An auth mode that requires a credential — every AuthModeWithKey except "none", keyed forms included.
Credentials
Credentials(token, apikey)Raw credentials extracted from an incoming HTTP request.
Produced by core.extractCredentials from the Authorization and apikey headers.
Parameters
- tokenRequiredOne of the following options
Bearer token from the
Authorization: Bearer <token>header, ornullif absent.- Option 1string
- Option 2null
- apikeyRequiredOne of the following options
API key from the
apikeyheader, ornullif absent.- Option 1string
- Option 2null
ErrorResponseConfig
ErrorResponseConfig(detailed?)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.
Parameters
- detailedOptionalboolean
Whether to include the diagnostic fields in the response body.
hintnames the likely misconfiguration,detailsreports the endpoint's accepted auth modes, which credential headers arrived, and the names of configured keys, anddocslinks the relevant reference. All three are aimed at whoever is building against the endpoint.Set to
falseto reduce the body tocodeandmessagealone. Thex-supabase-server-errorheader and the HTTP status are unaffected, andmessagekeeps its[@supabase/server]prefix — so the error stays traceable without thesourcefield.
// Full payload: source, code, message, hint, docs, detailswithSupabase({ auth: 'user' }, handler)// Trimmed: code and message onlywithSupabase({ auth: 'user', errors: { detailed: false } }, handler)JWTClaims
JWTClaims(sub, iss?, aud?, exp?, iat?, role?, email?, app_metadata?, user_metadata?)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.
Parameters
- subRequiredstring
Subject — the user's unique ID.
- issOptionalstring
Issuer — typically your Supabase project URL.
- audOptionalOne of the following options
Audience — who the token is intended for.
- Option 1string
- Option 2Array<string>
- expOptionalnumber
Expiration time (seconds since epoch).
- iatOptionalnumber
Issued at (seconds since epoch).
- roleOptionalstring
Supabase role (e.g.
"authenticated","anon"). - emailOptionalstring
User's email address from Supabase Auth.
- app_metadataOptionalRecord<string, unknown>
Application-level metadata set via Supabase Auth admin APIs.
- user_metadataOptionalRecord<string, unknown>
User-editable metadata set via Supabase Auth.
OAuthProtectedResourceConfig
OAuthProtectedResourceConfig(resourceServer?, authorizationServer?, errors?)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.
Parameters
- resourceServerOptionalOne of the following options
The resource identifier to advertise — this endpoint's externally-visible URL, which RFC 9728 §3.3 requires to equal the URL the client called.
Defaults to the Edge Functions derivation. Required on any other backend, usually from the request —
(req) => new URL(req.url).origin + '/api/mcp'. Unset there, every request other than the metadata route'sOPTIONSpreflight is answered with a500and codeMISSING_RESOURCE_SERVER.- Option 1string
- Option 2function
- authorizationServerOptionalOne of the following options
The OAuth 2.1 authorization server to advertise, as an issuer identifier.
Defaults to the project's Supabase Auth on Edge Functions. Elsewhere it falls back to
SUPABASE_PUBLIC_URL, thenSUPABASE_URL, each with/auth/v1appended; with neither set, the metadata route is answered with a500and codeMISSING_AUTHORIZATION_SERVER. Pass fromSupabaseUrl for a specific project, or any other issuer directly.- Option 1string
- Option 2function
- errorsOptionalErrorResponseConfig
How much of an error to include in a short-circuit response body.
ResourceMetadataOptions
ResourceMetadataOptions(resource?, authorizationServers?)Options for resourceMetadataResponse.
Parameters
- resourceOptionalstring
Override the resource URI.
- authorizationServersOptionalArray<string>
Override the authorization servers list.
ShortCircuitConfig
ShortCircuitConfig(errors?)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.
Parameters
- errorsOptionalErrorResponseConfig
How much of an error to include in a short-circuit response body.
SupabaseContext
SupabaseContext(supabase, supabaseAdmin, userClaims, jwtClaims, authMode, authKeyName?)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.
Parameters
- supabaseRequiredSupabaseClient
Supabase client scoped to the caller's identity. RLS policies apply.
- supabaseAdminRequiredSupabaseClient
Admin Supabase client that bypasses Row-Level Security.
- userClaimsRequiredOne of the following options
JWT-derived identity. For the full Supabase User object, call
supabase.auth.getUser().- Option 1UserClaims
- Option 2null
- jwtClaimsRequiredOne of the following options
Raw JWT payload.
nullfor non-user auth modes.- Option 1JWTClaims
- Option 2null
- authModeRequiredOne of the following options
The auth mode that was used for this request.
- Option 1"none"
- Option 2"publishable"
- Option 3"secret"
- Option 4"user"
- authKeyNameOptionalstring
The auth key name of the API key that was used for this request. Omitted for
'user'and'none'modes, which don't match a named key.
SupabaseEnv
SupabaseEnv(url, publishableKeys, secretKeys, jwks)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.
Parameters
- urlRequiredstring
Supabase project URL (e.g.
"https://<ref>.supabase.co"). Sourced fromSUPABASE_URL. - publishableKeysRequiredRecord<string, string>
Named publishable keys. Sourced from
SUPABASE_PUBLISHABLE_KEYS(JSON object) orSUPABASE_PUBLISHABLE_KEY(single key, stored as{ default: "<value>" }). - secretKeysRequiredRecord<string, string>
Named secret keys. Sourced from
SUPABASE_SECRET_KEYS(JSON object) orSUPABASE_SECRET_KEY(single key, stored as{ default: "<value>" }). - jwksRequiredOne of the following options
JWKS source used for JWT verification.
Sourced from one of (in priority order):
SUPABASE_JWKS— inline JSON. Resolves to aJSONWebKeySet.SUPABASE_JWKS_URL— remote endpoint. Resolves to aURL; keys are fetched lazily and cached in memory (cooldown / max-age handled byjose).https://is always accepted; plainhttp://is accepted only for loopback hosts (localhost,127.0.0.0/8,::1) to support the Supabase CLI. Any otherhttp://URL is rejected to prevent MITM swap-in of a forged signing key.
nullwhen no JWKS is configured (JWT verification will be unavailable). Each env var is authoritative when set: a malformed value resolves tonullrather than falling through to the other variable.- Option 1JSONWebKeySet
- Option 2URL
- Option 3null
UrlOption
A configured URL: either a fixed value, or derived per request.
UserClaims
UserClaims(id, role?, email?, appMetadata?, userMetadata?)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.
Parameters
- idRequiredstring
User's unique ID (same as
JWTClaims.sub). - roleOptionalstring
Supabase role (e.g.
"authenticated"). - emailOptionalstring
User's email address.
- appMetadataOptionalRecord<string, unknown>
Application-level metadata (e.g. roles, permissions).
- userMetadataOptionalRecord<string, unknown>
User-editable profile metadata (e.g. display name, avatar).
WithSupabaseConfig
WithSupabaseConfig(auth?, allow?, audience?, issuer?, env?, cors?, supabaseOptions?, errors?)Configuration for withSupabase and createSupabaseContext.
Controls which auth modes are accepted, environment overrides, and CORS behavior.
Parameters
- authOptionalOne of the following options
Auth mode(s) to accept. Modes are tried in order — the first match wins. A mode falls through only when its credential is absent; a present-but-invalid JWT short-circuits the chain with
InvalidJwtError(INVALID_JWT)."none"matches unconditionally, so it belongs last in a list or on its own — see AuthConfig.- Option 1"none"
- Option 2CredentialedAuthMode
- Option 3
- Option 4
- allowOptionalOne of the following options
- Option 1One of the following options
- Option 1One of the following options
- Option 1"none"
- Option 2"publishable"
- Option 3"secret"
- Option 4"user"
- Option 2
- Option 3
- Option 2Array<One of the following options>
- audienceOptionalOne of the following options
Accepted
audclaim value(s) forusermode. When set, a token withoutaudis rejected, and a token whoseaudis an array passes when any entry is accepted.- Option 1string
- Option 2Array<string>
- issuerOptionalOne of the following options
Accepted
issclaim value(s) forusermode. When set, a token withoutissis rejected. Supabase Auth issueshttps://<project-ref>.supabase.co/auth/v1, whichfromSupabaseUrl(url)builds from a project URL.- Option 1string
- Option 2Array<string>
- envOptionalPartial
Override auto-detected environment variables. Useful for testing or when running in environments without standard env var support.
- corsOptionalOne of the following options
CORS configuration for the
withSupabasewrapper.'default'— uses@supabase/supabase-jsdefault CORS headers.'disabled'— disables CORS handling entirely.{ headers }— custom CORS headers.
The boolean (
true/false) and bareRecord<string, string>forms are deprecated but still accepted for backward compatibility.- Option 1boolean
- Option 2Record<string, string>
- Option 3"default"
- Option 4"disabled"
- Option 5object
- supabaseOptionsOptionalSupabaseClientOptions
Options forwarded to both internal
createClient()calls.accessTokenis stripped, and auth settings (persistSession,autoRefreshToken,detectSessionInUrl) are force-overwritten to server-safe values. - errorsOptionalErrorResponseConfig
How much of an error to include in the response body.
// Require authenticated users, auto-CORS enabled (default)const config: WithSupabaseConfig = { auth: 'user' }// Accept users or service-to-service calls, custom CORS headersconst config: WithSupabaseConfig = { auth: ['user', 'secret'], cors: { 'Access-Control-Allow-Origin': 'https://myapp.com' },}// No auth required, CORS disabledconst config: WithSupabaseConfig = { auth: 'none', cors: 'disabled' }