Skip to content
Server Reference v1.0

服务器 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/server

Use via JSR (Deno / Bun)#

@supabase/server is also published to JSR for Deno and Bun environments.

deno add jsr:@supabase/server

Optional peer dependencies on Deno#

Some entry points rely on optional peer dependencies. @supabase/server/middleware/postgres and @supabase/server/middleware/postgres-admin need pg. Deno resolves an optional peer only when your own code imports it. A pin in deno.json alone is not enough: deno check passes and the function fails at startup with Could not find package 'pg'.

Add a bare import once, at the top of your entry module — no separate deno add npm:pg or package.json entry is needed. deno info lists npm:/pg@... once the package is in the module graph.

Deno 2.9 and later apply a minimum dependency age to npm packages. To use a release published the same day, pass --minimum-dependency-age 0 to deno check.

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 = false

Build the context yourself#

createSupabaseContext runs the same auth checks as withSupabase but returns { data, error } instead of a response. Use it inside a framework route handler, or anywhere you want to shape the error response yourself.

On success, data is the same SupabaseContext that withSupabase passes to your handler. On failure, error.status holds the HTTP status to send. createSupabaseContext does not handle CORS.

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.

What a bridge gives you#

An entry does three jobs. How many of them survive depends on the framework.

  • Context. The entries contribute typed keys such as supabase and jwtClaims, and your handler reads them.
  • Short-circuit. An entry can return a Response, and your route never runs. That is how withRequiredClaims rejects a request.
  • Response phase. An entry written as async function* can yield, see the response on the way out, and rewrite it. withCors needs this to stamp headers.
FrameworkContextShort-circuitResponse phasePattern
HonoYesYesYesmiddleware slot
H3 / NuxtYesYesYesmiddleware slot
ElysiaYesYesYeswrap app.handle
NestJSYesYesNoguard
TanStack StartYesYesYesmiddleware 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.

FrameworkOne array per route group
HonoOne sub-app per array, mounted with app.route(). A second app.use() statement is not enough.
H3 / Nuxtapp.use('/path', toH3(entries)) before the routes under that path
ElysiaOne 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 const
const 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 const
const 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 const
const 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#

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 configRejected beforeReplacement
auth: 'user', or no configmissing or invalid JWTwithRequiredClaims()
auth: 'none'nothingnothing; the client entries alone
auth: 'publishable'missing or wrong apikeynone. Keep withSupabase
auth: 'secret'missing or wrong apikeynone. 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.

RequestStatuscode
No Authorization header401MISSING_CREDENTIALS
An sb_* API key in the Authorization slot401UNUSABLE_CREDENTIAL
A token that fails verification401INVALID_JWT
A token, but the JWKS could not be fetched500JWKS_FETCH_FAILED
A token, but no JWKS source and no usable project URL500JWKS_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.

// Before
const { supabase, userClaims } = c.var.supabaseContext
// After
const supabase = c.var.supabase
const jwtClaims = c.var.jwtClaims

For supabase and supabaseAdmin the rewrite is mechanical. For claims it is not. No entry contributes userClaims. withClaims() and withRequiredClaims() both contribute jwtClaims, the raw JWT payload, and the field names differ.

userClaims (adapters)jwtClaims (middleware)
.id.sub
.role.role
.email.email
.appMetadata.app_metadata
.userMetadata.user_metadata

A blanket rename of userClaims to jwtClaims fails to compile in TypeScript. In plain JavaScript, or behind an as, jwtClaims.id is undefined with no error, and .id is usually the value rows get written with. Rewrite each read against the table.

Step by step#

Do the steps in order. The auth inventory comes before any code change, because the step that follows it is the one that can open an endpoint up. Follow the steps yourself, or hand them to a coding agent with the prompt at the end of this page.

1. Check your versions#

The bridges need @supabase/server 1.6.0 or later and Node 22 or later.

Then decide whether you need the response phase: an entry that sees the outgoing response and can rewrite it, which is what CORS and header-stamping middleware do. The table at the top of this page says what survives per framework. If you only read supabase inside a handler, you do not need it.

npm ls @supabase/server # 1.6.0 or later
node --version # 22 or later

2. Inventory every adapter registration#

Do this before editing anything. What you need is in the adapter config, not in the handlers, and it stops being visible the moment you start swapping imports.

For each hit, find the withSupabase(...) call it feeds and write down its auth value. A bare withSupabase() with no config counts as auth: 'user'. Keep that list. Step 7 checks against it.

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 config
toHono([withRequiredClaims(), withSupabaseClient()])
// auth: 'none'
toHono([withSupabaseClient()])
// auth: 'publishable' or auth: 'secret'
// Stop. Keep withSupabase for this endpoint.

4. Copy the bridge for your framework#

One file, yours to own from here on: src/lib/supabase-middleware.ts, or src/lib/supabase.guard.ts for NestJS. Copy it as is. Some lines look redundant and are not. The Hono c.res clear-then-assign is the clearest example, and it carries the comment explaining why. Keep the comments.

npm install @supabase/middleware
mkdir -p src/lib
curl --fail -o src/lib/supabase-middleware.ts \
https://raw.githubusercontent.com/supabase/server/main/examples/frameworks/hono/supabase-middleware.ts

5. Swap the registrations#

One at a time, using the decision from step 3. Add withSupabaseAdminClient() from @supabase/server/middleware/admin-client only where the old code read supabaseAdmin. It needs the secret key.

// Before
import { withSupabase } from '@supabase/server/adapters/hono'
app.use('*', withSupabase({ auth: 'user' }))
// After
import { withRequiredClaims } from '@supabase/server/middleware/required-claims'
import { withSupabaseClient } from '@supabase/server/middleware/client'
import { toHono } from './lib/supabase-middleware.js'
app.use('*', toHono([withRequiredClaims(), withSupabaseClient()]))

6. Rewrite the call sites#

The adapters exposed one nested object. The entries contribute flat keys. The same shape applies to event.context (H3), the route context (Elysia), and req (NestJS).

userClaims is not on that list. No entry contributes it. Rewrite each claims read against the field table in "The shape change" above.

c.var.supabaseContext.supabase -> c.var.supabase
c.var.supabaseContext.supabaseAdmin -> c.var.supabaseAdmin
c.var.supabaseContext.userClaims.id -> c.var.jwtClaims.sub
grep -rn "supabaseContext" src/ # expect no results

7. Verify#

Three checks, in this order. The second is what this procedure exists for.

Types: run the typecheck.

Auth: for every endpoint step 2 recorded as rejecting, prove it still rejects. A clean typecheck says nothing about it. A 200 on the first call is the failure this page is arranged around. It means the gate is missing, not that the endpoint is healthy.

Behavior: run your test suite, then exercise one migrated endpoint end to end. If it was behind auth: 'user', sign in and confirm the handler sees the caller: jwtClaims.sub is the user id.

The rejection body changed even when the status did not. See the code table in "The auth trap" above.

npx tsc --noEmit
BASE=http://localhost:3000
# no credentials -> expect 401
curl -s -o /dev/null -w "%{http_code}\n" $BASE/your-endpoint
# a valid user token -> expect 200
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $TOKEN" $BASE/your-endpoint

8. Clean up#

Anything left is either an endpoint you kept on purpose (the publishable and secret cases from step 3) or one you missed. Uninstall the framework peer dependency only if nothing else uses it.

grep -rn "@supabase/server/adapters" src/

Call-site cheat sheet#

BeforeAfter
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.supabasec.var.supabase
c.var.supabaseContext.supabaseAdminc.var.supabaseAdmin
c.var.supabaseContext.userClaims.idc.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 the
order below. Do not start at step 3.
STEP 1: INVENTORY. Do this before editing anything.
Grep for `@supabase/server/adapters` and list every withSupabase(...)
registration it feeds, with that call's `auth` value. A bare withSupabase()
with no config means auth: 'user'. Show me this list before you edit.
STEP 2: DECIDE THE AUTH REPLACEMENT for each one. This is the step that can
open an endpoint up, so do it deliberately:
auth: 'user' (or no config) -> withRequiredClaims() from
'@supabase/server/middleware/required-claims'
auth: 'none' -> no gate needed
auth: 'publishable'/'secret' -> STOP. There is no composable gate. Leave the
endpoint on withSupabase and tell me about it. Do not hand-roll a key
check.
The adapters REJECTED unauthenticated requests. withClaims() does NOT: it
contributes jwtClaims: null and falls through, turning a 401 endpoint into a
200 endpoint silently. Never use withClaims() as the replacement for
auth: 'user'. Never compose withClaims() and withRequiredClaims() together;
they share the jwtClaims key and that is a compile-time conflict.
STEP 3: FETCH THE BRIDGE. Add @supabase/middleware as a direct dependency of
the project; the bridge imports it. Then download
https://raw.githubusercontent.com/supabase/server/main/examples/frameworks/hono/supabase-middleware.ts
and save it as src/lib/supabase-middleware.ts, verbatim. Do not "improve",
condense, or drop comments from it.
STEP 4: SWAP each registration to the bridge called on an entry array, e.g.
toHono([withRequiredClaims(), withSupabaseClient()])
For NestJS, call toNestGuard(entries) once, assign it to a const, and pass
that const to every @UseGuards().
Import the client entries from '@supabase/server/middleware/client' and
'@supabase/server/middleware/admin-client'. Only include
withSupabaseAdminClient() where the old code actually read supabaseAdmin.
STEP 5: REWRITE call sites from the nested shape to flat keys:
c.var.supabaseContext.supabase -> c.var.supabase
c.var.supabaseContext.supabaseAdmin -> c.var.supabaseAdmin
Apply the equivalent for event.context / the Elysia context / req.
userClaims is NOT one of these. No middleware contributes that key;
c.var.userClaims does not exist. The entries contribute jwtClaims, the RAW JWT
payload, with different field names:
userClaims.id -> jwtClaims.sub
userClaims.role -> jwtClaims.role
userClaims.email -> jwtClaims.email
userClaims.appMetadata -> jwtClaims.app_metadata
userClaims.userMetadata -> jwtClaims.user_metadata
Rewrite each read individually. Do NOT do a blanket rename; in plain
JavaScript that leaves .id undefined with no error.
When finished there must be no remaining reference to `supabaseContext`.
STEP 6: VERIFY. Run the typecheck. Then, for every endpoint you listed in
step 1 as rejecting, confirm an unauthenticated request still returns 401 and
an authenticated one still returns 200. A passing typecheck does not prove
this; check it separately.
CONSTRAINTS
- Do NOT remove the `c.res = undefined` line in the Hono bridge or its comment.
It looks redundant and is not: when a response-phase entry returns a new
Response, Hono merges the previous response's headers over it, reverting
anything the entry rewrote.
- On Hono, put a second entry array in its own sub-app mounted with
app.route(). A second app.use() statement leaves c.var untyped.
- On NestJS, do not put withCors in the guard's array. CORS is
app.enableCors().
REPORT, as a table: every adapter registration you found, its old `auth` value,
and whether the migrated version still rejects anonymous requests. Then list
every file you changed and anything you could not migrate.

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.

  • options
    Optional
    WithSupabaseConfig

    Auth modes, environment overrides. The cors option is ignored here.

Return Type

Promise<One of the following options>
  • 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 to createClient()).

Return Type

string
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

string
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

  • max
    Optional
    number

    Connections this process opens at most on this connection string. A positive integer. The pooler team recommends one to four for serverless deployments.

  • checkoutTimeoutMs
    Optional
    number

    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

  • role
    Optional
    string

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
  • options
    Optional
    ResourceMetadataOptions

unauthorizedResponse

unauthorizedResponse(req, options?)

401 response with a WWW-Authenticate: Bearer resource_metadata="..." header (RFC 9728). The metadata URL defaults to the Edge Functions derivation and throws off platform; pass resourceMetadataUrl to override for custom setups.

Parameters

  • reqRequest
  • options
    Optional
    UnauthorizedResponseOptions

withClaims

Contributes ctx.jwtClaims by verifying the caller's Bearer token against the project JWKS — the same verification core withSupabase uses for its user auth mode.

Use this when composing a standalone pipeline([...], handler) that is not wrapped by withSupabase — for example a Supabase-agnostic Edge Function that still wants the caller's verified claims available to a downstream middleware such as withPostgresClient. Inside withSupabase, the context already carries jwtClaims, so withClaims is unnecessary.

Behavior:

  • No Authorization: Bearer token (or an sb_* API key in that position) → contributes null; the request proceeds as anonymous.
  • Token present but invalid → short-circuits with a 401 JSON response ({ message, code }, matching withSupabase's error shape).
  • Token present but no JWKS configured → short-circuits with a 500 — verification is not optional; there is no decode-only mode.

withClaims is not an auth gate. It never rejects a request that has no token. A pipeline like [withClaims(), withSupabaseClient()] accepts anonymous callers and is not the composable form of withSupabase({ auth: 'user' }), which rejects token-less requests with a 401. To require an authenticated caller, compose withRequiredClaims from @supabase/server/middleware/required-claims instead. The two entries share the jwtClaims key, so a pipeline picks "claims if present" or "claims required"; composing both is a compile-time conflict.

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

  • jwks
    Optional
    One 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) or SUPABASE_JWKS_URL (https endpoint) from the environment.

    • Option 1JSONWebKeySet
    • Option 2URL
  • audience
    Optional
    One of the following options

    Accepted aud claim value(s). When set, a token without aud is rejected, and a token whose aud is an array passes when any entry is accepted.

    • Option 1string
    • Option 2Array<string>
  • issuer
    Optional
    One of the following options

    Accepted iss claim value(s). When set, a token without iss is rejected. Supabase Auth issues https://<project-ref>.supabase.co/auth/v1, which fromSupabaseUrl(url) builds from a project URL.

    • Option 1string
    • Option 2Array<string>
  • errors
    Optional
    ErrorResponseConfig

    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 the OPTIONS preflight, so browser-based clients can read it)
  • Enriches a 401 from the inner handler with WWW-Authenticate: Bearer resource_metadata="...", unless the handler already set a WWW-Authenticate header (its value wins)
  • Passes any other path through to the inner handler unchanged (composition, not routing, decides what happens to it)
  • Answers a default URL it cannot derive with the JSON error response withSupabase returns for its own configuration failures (500, x-supabase-server-error); a throw from a configured resourceServer or authorizationServer function is the caller's and propagates

The metadata route is matched on the path suffix, so any GET or OPTIONS ending in /oauth-protected-resource is answered here and never reaches the inner handler, at any depth. Other methods pass through.

Zero-config on Supabase Edge Functions. Elsewhere OAuthProtectedResourceConfig.resourceServer is required and OAuthProtectedResourceConfig.authorizationServer falls back to SUPABASE_URL; one that cannot be resolved is reported as MISSING_RESOURCE_SERVER / MISSING_AUTHORIZATION_SERVER.

Contributes ctx.oauthProtectedResource (the resolved metadata URL) to the downstream context. Nested under withSupabase, the key is typed on the handler's ctx when the outermost call is anchored with satisfies FetchHandler — see withSupabase's type note. Placed directly after withSupabase with an auth mode that requires credentials, the composition is refused when the stack is built, since the gate would answer discovery and preflight before this middleware runs.

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 where clause you write. Reach for withPostgresClient unless you specifically need to cross user boundaries.

Runtime note. pg needs raw TCP, so this runs on Node/Deno (including the Supabase Edge runtime), not on Workers-style isolates.


WithPostgresAdminClientConfig

WithPostgresAdminClientConfig(connectionString?, pool?, errors?)

Configuration for withPostgresAdminClient.

Parameters

  • connectionString
    Optional
    string

    Defaults to getEnv('SUPABASE_DB_URL') (from @supabase/middleware).

  • pool
    Optional
    PostgresPoolOptions

    Pool size and checkout timeout for this connection string. Checked when the middleware is built; an invalid value throws a RangeError then. Entries whose options resolve to the same values share one pool.

  • errors
    Optional
    ErrorResponseConfig

    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() resolves
set 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 role claim, and RLS still applies to them. They are not supported here yet, so such a token is refused rather than downgraded.

Reads the caller's claims from ctx.jwtClaims, which withSupabase populates (JWKS-verified) — so after withSupabase in a pipeline it composes directly:

pipeline([withSupabase({ auth: 'user' }), withPostgresClient()], handler)

Standalone (no withSupabase), pair it with withClaims so ctx.jwtClaims is present before it runs.

Table grants. Queries run as authenticated or anon, so those roles need explicit table privileges (e.g. grant select, insert on <table> to authenticated) in addition to RLS policies. A missing grant fails with permission denied (SQLSTATE 42501) before RLS is consulted.

Runtime note. pg needs raw TCP, so this runs on Node/Deno (including the Supabase Edge runtime), not on Workers-style isolates.


WithPostgresClientConfig

WithPostgresClientConfig(connectionString?, pool?, errors?)

Configuration for withPostgresClient.

Parameters

  • connectionString
    Optional
    string

    Defaults to getEnv('SUPABASE_DB_URL') (from @supabase/middleware).

  • pool
    Optional
    PostgresPoolOptions

    Pool size and checkout timeout for this connection string. Checked when the middleware is built; an invalid value throws a RangeError then. Entries whose options resolve to the same values share one pool.

  • errors
    Optional
    ErrorResponseConfig

    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: Bearer token → 401 MISSING_CREDENTIALS. The handler never runs.
  • An sb_* API key in that position → 401 UNUSABLE_CREDENTIAL: a credential arrived, just not a user JWT.
  • Token present but invalid → 401 INVALID_JWT, naming the specific reason.
  • Token present but no JWKS configured → 500 JWKS_NOT_CONFIGURED; verification is not optional and there is no decode-only mode.

Because the contribution is non-null, gated handlers read ctx.jwtClaims directly, with no ?.sub ?? 'anon' fallbacks. Downstream entries declaring a jwtClaims prerequisite, such as withPostgresClient, compose with no further verification.

The 401 and 500 short-circuits carry no CORS headers, and a bare pipeline answers no OPTIONS preflight. For browser callers, compose withCors (@supabase/middleware/cors) ahead of the gate: it answers preflight before the gate runs and stamps Access-Control-* headers on the short-circuit responses.

After withSupabase in a pipeline the context already carries verified jwtClaims, so this gate is unnecessary there and placing it after withSupabase is a compile-time conflict. Use withSupabase({ auth: 'user' }) to gate that path.

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

  • jwks
    Optional
    One 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) or SUPABASE_JWKS_URL (https endpoint) from the environment.

    • Option 1JSONWebKeySet
    • Option 2URL
  • audience
    Optional
    One of the following options

    Accepted aud claim value(s). When set, a token without aud is rejected, and a token whose aud is an array passes when any entry is accepted.

    • Option 1string
    • Option 2Array<string>
  • issuer
    Optional
    One of the following options

    Accepted iss claim value(s). When set, a token without iss is rejected. Supabase Auth issues https://<project-ref>.supabase.co/auth/v1, which fromSupabaseUrl(url) builds from a project URL.

    • Option 1string
    • Option 2Array<string>
  • errors
    Optional
    ErrorResponseConfig

    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 Request and a fully-initialized SupabaseContext.

Return Type

function
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

  • config
    Optional
    WithSupabaseAdminClientConfig
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

  • config
    Optional
    WithSupabaseClientConfig
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

  • options
    Optional
    CreateAdminClientOptions
// 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

  • options
    Optional
    CreateContextClientOptions
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 as token
  • apikey: <key> → extracted as apikey

This is a pure extraction step — no validation or verification is performed. Pass the result to verifyCredentials to validate against allowed auth modes.

Parameters

  • requestRequest

    The incoming HTTP request.

Return Type

Credentials
import { extractCredentials } from '@supabase/server/core'
const creds = extractCredentials(request)
console.log(creds.token) // "eyJhbGci..." or null
console.log(creds.apikey) // "sb-abc123-publishable-..." or null

resolveEnv

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

  • overrides
    Optional
    Partial

    Partial values that take precedence over env vars.

Return Type

One of the following options
  • Option 1object
  • Option 2object
const { data: env, error } = resolveEnv()
if (error) throw error
// Override for tests
const { 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

Promise<One of the following options>
  • 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

  • auth
    Optional
    One 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>
  • allow
    Optional
    One 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>
  • audience
    Optional
    One of the following options

    Accepted aud claim value(s) for user mode. When set, a token without aud is rejected, and a token whose aud is an array passes when any entry is accepted.

    • Option 1string
    • Option 2Array<string>
  • issuer
    Optional
    One of the following options

    Accepted iss claim value(s) for user mode. When set, a token without iss is rejected. Supabase Auth issues https://<project-ref>.supabase.co/auth/v1, which fromSupabaseUrl(url) builds from a project URL.

    • Option 1string
    • Option 2Array<string>
  • env
    Optional
    Partial

    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

Promise<One of the following options>
  • 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

  • auth
    Optional
    One 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>
  • allow
    Optional
    One 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>
  • audience
    Optional
    One of the following options

    Accepted aud claim value(s) for user mode. When set, a token without aud is rejected, and a token whose aud is an array passes when any entry is accepted.

    • Option 1string
    • Option 2Array<string>
  • issuer
    Optional
    One of the following options

    Accepted iss claim value(s) for user mode. When set, a token without iss is rejected. Supabase Auth issues https://<project-ref>.supabase.co/auth/v1, which fromSupabaseUrl(url) builds from a project URL.

    • Option 1string
    • Option 2Array<string>
  • env
    Optional
    Partial

    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

  • status
    Required
    number
  • cause
    Required
    AuthError

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

  • config
    Optional
    Omit

    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

  • source
    Required
    "@supabase/server"

    Always "@supabase/server".

  • code
    Required
    string

    Machine-readable error code.

  • hint
    Optional
    string

    Actionable next step, when one applies.

  • docs
    Required
    string

    Documentation URL for this error's code.

  • details
    Optional
    Record<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.

  • status
    Required
    number

    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

  • authModes
    Required
    Array<string>

    Auth modes that were attempted, in the order they were tried.

  • received
    Required
    ReceivedCredentials

    Which credential headers the request carried.

  • configuredKeyNames
    Optional
    Record<string, Array<string>>

    Names (never values) of the keys configured for the attempted modes. Omitted for modes that don't use API keys.

  • matchedKey
    Optional
    object

    Set when the rejected apikey is a configured key of kind held under name, and mode is 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

  • source
    Required
    "@supabase/server"

    Always "@supabase/server".

  • code
    Required
    string

    Machine-readable error code.

  • hint
    Optional
    string

    Actionable next step, when one applies.

  • docs
    Required
    string

    Documentation URL for this error's code.

  • details
    Optional
    Record<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.

  • status
    Required
    "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

  • source
    Required
    "@supabase/server"

    Always "@supabase/server". Identifies which library produced the error.

  • code
    Required
    string

    Machine-readable error code, also sent as the x-supabase-server-error header.

  • message
    Required
    string

    Human-readable description, prefixed with [@supabase/server].

  • hint
    Optional
    string

    Actionable next step. Omitted when there isn't a useful one.

  • docs
    Required
    string

    Documentation URL for this specific code.

  • details
    Optional
    Record<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.


MissingAuthorizationServerError

withOAuthProtectedResource has no authorizationServer and cannot derive one.


MissingConnectionStringError

No Postgres connection string is configured, so the withPostgresClient / withPostgresAdminClient middleware cannot connect.


MissingCredentialsError

The request carried no credentials at all — neither an apikey header nor an Authorization: Bearer token — and no accepted auth mode allows that.


MissingDefaultPublishableKeyError

No default publishable key found.


MissingDefaultSecretKeyError

No default secret key found.


MissingPublishableKeyError

Named publishable key not found in SUPABASE_PUBLISHABLE_KEYS.


MissingResourceServerError

withOAuthProtectedResource has no resourceServer and cannot derive one.


MissingSecretKeyError

Named secret key not found in SUPABASE_SECRET_KEYS.


MissingSupabaseURLError

SUPABASE_URL is not set.


NoKeysConfiguredError

An "publishable" or "secret" auth mode was requested but no keys of that kind are configured, so the mode could never match. Server misconfiguration (status: 500).


PostgresConnectPausedError

A connection attempt failed and the pool is pausing new attempts, so a query that needed a new connection was refused without trying. cause is the failure and details.retryAfterMs the time left in the pause.


PostgresPoolBusyError

Every pooled connection stayed busy for the whole checkout wait, so the query could not get one. details.max is the pool size and details.waitedMs the wait.


PostgresPoolError

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

  • source
    Required
    "@supabase/server"

    Always "@supabase/server".

  • code
    Required
    string

    Machine-readable error code.

  • hint
    Optional
    string

    Actionable next step, when one applies.

  • docs
    Required
    string

    Documentation URL for this error's code.

  • details
    Optional
    Record<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.

  • status
    Required
    "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

  • authorization
    Required
    One of the following options

    What the Authorization header carried.

    • 'bearer' — a bearer token that looks like a JWT
    • 'api-key' — an sb_* API key, which the Supabase SDK forwards here alongside the apikey header; 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"
  • apikey
    Required
    One of the following options

    The apikey header 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

  • source
    Required
    "@supabase/server"

    Always "@supabase/server".

  • status
    Required
    number

    HTTP status code appropriate for this error.

  • code
    Required
    string

    Machine-readable error code.

  • hint
    Optional
    string

    Actionable next step, when one applies.

  • docs
    Required
    string

    Documentation URL for this error's code.

  • details
    Optional
    Record<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

  • hint
    Optional
    string

    Actionable next step for whoever has to fix this.

  • details
    Optional
    Record<string, unknown>

    Structured, non-sensitive diagnostics. Never contains key values, token payloads, or any other secret material.

  • docs
    Optional
    string

    Override the generated documentation URL.

  • cause
    Optional
    unknown

    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 mode
withSupabase({ auth: ['secret', 'user'] }, handler) // first match wins
withSupabase({ auth: ['user', 'none'] }, handler) // optional user
withSupabase({ auth: 'none' }, handler) // no credentials required

AuthMode

Authentication mode that determines what credentials a request must provide.

  • "none" — No credentials required. Every request is accepted.
  • "publishable" — Requires a valid publishable key in the apikey header. Matches only the default key.
  • "secret" — Requires a valid secret key in the apikey header (timing-safe comparison). Matches only the default key.
  • "user" — Requires a valid JWT in the Authorization: Bearer <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 mode
withSupabase({ auth: 'user' }, handler)
// Multiple modes — the first match wins.
// A mode is tried only when its credential is present; a JWT that is
// present but fails verification rejects immediately rather than falling
// through to the next mode.
withSupabase({ auth: ['user', 'publishable'] }, handler)

AuthModeWithKey

Extended auth mode that supports targeting a specific named key.

Use the colon syntax ("publishable:web_app") to require a specific named key from the SUPABASE_PUBLISHABLE_KEYS or SUPABASE_SECRET_KEYS JSON object. Use "publishable:*" or "secret:*" to accept any key in the set. The bare form without a colon ("publishable" / "secret") matches only the default key.

// Accept only the "mobile" publishable key
withSupabase({ auth: 'publishable:mobile' }, handler)
// Accept any secret key
withSupabase({ auth: 'secret:*' }, handler)
// Mix named keys with other modes
withSupabase({ auth: ['user', 'publishable:web_app'] }, handler)

AuthResult

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

  • authMode
    Required
    One of the following options

    The auth mode that was successfully matched.

    • Option 1"none"
    • Option 2"publishable"
    • Option 3"secret"
    • Option 4"user"
  • token
    Required
    One of the following options

    The verified JWT, or null for non-user auth modes.

    • Option 1string
    • Option 2null
  • userClaims
    Required
    One of the following options

    Normalized user identity derived from the JWT, or null when no JWT is present.

    • Option 1UserClaims
    • Option 2null
  • jwtClaims
    Required
    One of the following options

    Raw JWT payload, or null when no JWT is present.

    • Option 1JWTClaims
    • Option 2null
  • keyName
    Optional
    One of the following options

    Name of the matched key (e.g. "default", "mobile"), or null for "user" / "none" modes.

    • Option 1string
    • Option 2null

ClientAuth

ClientAuth(token?, keyName?)

Auth identity for client creation functions.

Parameters

  • token
    Optional
    One of the following options

    The caller's JWT, or null for anonymous access.

    • Option 1string
    • Option 2null
  • keyName
    Optional
    One 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

  • auth
    Optional

    Auth identity — key name from the verified request.

  • env
    Optional
    Partial

    Override auto-detected environment variables.

  • supabaseOptions
    Optional
    SupabaseClientOptions

    Options forwarded to createClient(). accessToken is stripped; auth settings are force-overwritten.


CreateContextClientOptions

CreateContextClientOptions(auth?, env?, supabaseOptions?)

Options for core.createContextClient.

Parameters

  • auth
    Optional
    ClientAuth

    Auth identity — token and key name from the verified request.

  • env
    Optional
    Partial

    Override auto-detected environment variables.

  • supabaseOptions
    Optional
    SupabaseClientOptions

    Options forwarded to createClient(). accessToken is 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

  • token
    Required
    One of the following options

    Bearer token from the Authorization: Bearer <token> header, or null if absent.

    • Option 1string
    • Option 2null
  • apikey
    Required
    One of the following options

    API key from the apikey header, or null if 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

  • detailed
    Optional
    boolean

    Whether to include the diagnostic fields in the response body.

    hint names the likely misconfiguration, details reports the endpoint's accepted auth modes, which credential headers arrived, and the names of configured keys, and docs links the relevant reference. All three are aimed at whoever is building against the endpoint.

    Set to false to reduce the body to code and message alone. The x-supabase-server-error header and the HTTP status are unaffected, and message keeps its [@supabase/server] prefix — so the error stays traceable without the source field.

// Full payload: source, code, message, hint, docs, details
withSupabase({ auth: 'user' }, handler)
// Trimmed: code and message only
withSupabase({ auth: 'user', errors: { detailed: false } }, handler)

JWTClaims

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

  • sub
    Required
    string

    Subject — the user's unique ID.

  • iss
    Optional
    string

    Issuer — typically your Supabase project URL.

  • aud
    Optional
    One of the following options

    Audience — who the token is intended for.

    • Option 1string
    • Option 2Array<string>
  • exp
    Optional
    number

    Expiration time (seconds since epoch).

  • iat
    Optional
    number

    Issued at (seconds since epoch).

  • role
    Optional
    string

    Supabase role (e.g. "authenticated", "anon").

  • email
    Optional
    string

    User's email address from Supabase Auth.

  • app_metadata
    Optional
    Record<string, unknown>

    Application-level metadata set via Supabase Auth admin APIs.

  • user_metadata
    Optional
    Record<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

  • resourceServer
    Optional
    One 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's OPTIONS preflight is answered with a 500 and code MISSING_RESOURCE_SERVER.

    • Option 1string
    • Option 2function
  • authorizationServer
    Optional
    One 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, then SUPABASE_URL, each with /auth/v1 appended; with neither set, the metadata route is answered with a 500 and code MISSING_AUTHORIZATION_SERVER. Pass fromSupabaseUrl for a specific project, or any other issuer directly.

    • Option 1string
    • Option 2function
  • errors
    Optional
    ErrorResponseConfig

    How much of an error to include in a short-circuit response body.


ResourceMetadataOptions

ResourceMetadataOptions(resource?, authorizationServers?)

Options for resourceMetadataResponse.

Parameters

  • resource
    Optional
    string

    Override the resource URI.

  • authorizationServers
    Optional
    Array<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

  • errors
    Optional
    ErrorResponseConfig

    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

  • supabase
    Required
    SupabaseClient

    Supabase client scoped to the caller's identity. RLS policies apply.

  • supabaseAdmin
    Required
    SupabaseClient

    Admin Supabase client that bypasses Row-Level Security.

  • userClaims
    Required
    One of the following options

    JWT-derived identity. For the full Supabase User object, call supabase.auth.getUser().

    • Option 1UserClaims
    • Option 2null
  • jwtClaims
    Required
    One of the following options

    Raw JWT payload. null for non-user auth modes.

    • Option 1JWTClaims
    • Option 2null
  • authMode
    Required
    One 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"
  • authKeyName
    Optional
    string

    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

  • url
    Required
    string

    Supabase project URL (e.g. "https://<ref>.supabase.co"). Sourced from SUPABASE_URL.

  • publishableKeys
    Required
    Record<string, string>

    Named publishable keys. Sourced from SUPABASE_PUBLISHABLE_KEYS (JSON object) or SUPABASE_PUBLISHABLE_KEY (single key, stored as { default: "<value>" }).

  • secretKeys
    Required
    Record<string, string>

    Named secret keys. Sourced from SUPABASE_SECRET_KEYS (JSON object) or SUPABASE_SECRET_KEY (single key, stored as { default: "<value>" }).

  • jwks
    Required
    One of the following options

    JWKS source used for JWT verification.

    Sourced from one of (in priority order):

    • SUPABASE_JWKS — inline JSON. Resolves to a JSONWebKeySet.
    • SUPABASE_JWKS_URL — remote endpoint. Resolves to a URL; keys are fetched lazily and cached in memory (cooldown / max-age handled by jose). https:// is always accepted; plain http:// is accepted only for loopback hosts (localhost, 127.0.0.0/8, ::1) to support the Supabase CLI. Any other http:// URL is rejected to prevent MITM swap-in of a forged signing key.

    null when no JWKS is configured (JWT verification will be unavailable). Each env var is authoritative when set: a malformed value resolves to null rather than falling through to the other variable.

    • Option 1JSONWebKeySet
    • Option 2URL
    • Option 3null

UnauthorizedResponseOptions

UnauthorizedResponseOptions(resourceMetadataUrl?)

Options for unauthorizedResponse.

Parameters

  • resourceMetadataUrl
    Optional
    string

    Absolute URL override for the resource metadata endpoint.


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

  • id
    Required
    string

    User's unique ID (same as JWTClaims.sub).

  • role
    Optional
    string

    Supabase role (e.g. "authenticated").

  • email
    Optional
    string

    User's email address.

  • appMetadata
    Optional
    Record<string, unknown>

    Application-level metadata (e.g. roles, permissions).

  • userMetadata
    Optional
    Record<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

  • auth
    Optional
    One 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
  • allow
    Optional
    One 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>
  • audience
    Optional
    One of the following options

    Accepted aud claim value(s) for user mode. When set, a token without aud is rejected, and a token whose aud is an array passes when any entry is accepted.

    • Option 1string
    • Option 2Array<string>
  • issuer
    Optional
    One of the following options

    Accepted iss claim value(s) for user mode. When set, a token without iss is rejected. Supabase Auth issues https://<project-ref>.supabase.co/auth/v1, which fromSupabaseUrl(url) builds from a project URL.

    • Option 1string
    • Option 2Array<string>
  • env
    Optional
    Partial

    Override auto-detected environment variables. Useful for testing or when running in environments without standard env var support.

  • cors
    Optional
    One of the following options

    CORS configuration for the withSupabase wrapper.

    • 'default' — uses @supabase/supabase-js default CORS headers.
    • 'disabled' — disables CORS handling entirely.
    • { headers } — custom CORS headers.

    The boolean (true/false) and bare Record<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
  • supabaseOptions
    Optional
    SupabaseClientOptions

    Options forwarded to both internal createClient() calls.

    accessToken is stripped, and auth settings (persistSession, autoRefreshToken, detectSessionInUrl) are force-overwritten to server-safe values.

  • errors
    Optional
    ErrorResponseConfig

    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 headers
const config: WithSupabaseConfig = {
auth: ['user', 'secret'],
cors: { 'Access-Control-Allow-Origin': 'https://myapp.com' },
}
// No auth required, CORS disabled
const config: WithSupabaseConfig = { auth: 'none', cors: 'disabled' }