# @supabase/server [![License](https://img.shields.io/npm/l/nx.svg?style=flat-square)](./LICENSE) [![Package](https://img.shields.io/npm/v/@supabase/server)](https://www.npmjs.com/package/@supabase/server) [![pkg.pr.new](https://pkg.pr.new/badge/supabase/server)](https://pkg.pr.new/~/supabase/server) [![Docs](https://img.shields.io/badge/docs-supabase.github.io-3ECF8E?logo=readthedocs&logoColor=white)](https://supabase.github.io/server/) > **Coming from a `0.x` release?** See [MIGRATION.md](MIGRATION.md) for the v0 → v1 rename map (`allow` → `auth`, `'public'` → `'publishable'`, `authType` → `authMode`, `claims` → `jwtClaims`, …). `@supabase/server` gives you batteries included access to the [supabase-js SDK](https://github.com/supabase/supabase-js), including client creation and authentication automatically scoped to the inbound requests to your Edge Functions and APIs. ```ts import { withSupabase } from '@supabase/server' export default { fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => { // RLS-scoped — this user only sees their own favorites const { data: myGames } = await ctx.supabase.from('favorite_games').select() return Response.json(myGames) }), } ``` One import. One line of config. Auth is validated, clients are ready, CORS is handled. Your handler only runs on successful auth. ## Installation ```bash # Deno / Supabase Edge Functions (no install — import directly) import { withSupabase } from "npm:@supabase/server@1"; # npm npm install @supabase/server # pnpm pnpm add @supabase/server ``` ### AI coding skills Install the skill so your AI coding agent (Claude Code, Cursor, etc.) knows how to use this package: ```bash npx skills add supabase/server ``` ## Quick Start Imagine you're building an app where users track their favorite games. They sign in and manage their own list. Pre-login screens browse the public catalog. An admin dashboard curates featured titles. A cron job refreshes the "popular this week" rankings. Here's how each piece looks: ### Authenticated endpoint ```ts // A signed-in user fetches their favorite games. export default { fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => { const { supabase, supabaseAdmin, userClaims, jwtClaims, authMode } = ctx // supabase — RLS-scoped to the authenticated user // supabaseAdmin — bypasses RLS (service role) // userClaims — user identity from JWT (id, email, role) // jwtClaims — full JWT claims // authMode — which auth mode matched // RLS-scoped — this user only sees their own favorites const { data: myGames } = await supabase.from('favorite_games').select() return Response.json(myGames) }), } ``` ### Public endpoint (no auth) ```ts // The frontend hits this before showing the login screen. // auth: 'none' means no credentials required. export default { fetch: withSupabase({ auth: 'none' }, async (_req, _ctx) => { return Response.json({ status: 'ok' }) }), } ``` ### Publishable-key endpoint ```ts // The mobile app browses the game catalog before the user signs in. // auth: 'publishable' validates the apikey header against the 'default' publishable key — // gating the endpoint to your own clients while staying anonymous to the DB. export default { fetch: withSupabase({ auth: 'publishable' }, async (_req, ctx) => { // ctx.supabase — anonymous (anon role); RLS still applies // ctx.userClaims, ctx.jwtClaims — null (no JWT) // ctx.authMode === 'publishable', ctx.authKeyName === 'default' const { data: catalog } = await ctx.supabase .from('games') .select('id, name, cover_url') return Response.json(catalog) }), } ``` The mobile app sends the publishable key in the `apikey` header: ```ts const catalogEndpoint = 'https://.supabase.co/functions/v1/catalog' const publishableKey = 'sb_publishable_...' await fetch(catalogEndpoint, { headers: { apikey: publishableKey } }) ``` > Unlike `auth: 'secret'`, the `supabase` client here is anonymous, not admin — RLS is the source of truth for what's visible. The publishable key acts as a coarse "this request came from a known client" gate; it isn't a user identity. ### API key protected ```ts // An admin dashboard fetches the list of featured games to curate. // auth: 'secret' validates the apikey header against the 'default' secret key // (not a user JWT) — supabaseAdmin bypasses RLS. export default { fetch: withSupabase({ auth: 'secret' }, async (_req, ctx) => { const { data: featuredGames } = await ctx.supabaseAdmin .from('featured_games') .select() return Response.json(featuredGames) }), } ``` ### Dual auth (user or service) ```ts // Users view their own play stats from the app (JWT). // A backend service pulls stats for any user (secret key + user_id in body). export default { fetch: withSupabase({ auth: ['user', 'secret'] }, async (req, ctx) => { const callerIsUser = ctx.authMode === 'user' if (callerIsUser) { // RLS-scoped — the database enforces "own stats only" const { data: myStats } = await ctx.supabase.from('play_stats').select() return Response.json(myStats) } // Service path — bypass RLS to pull stats for any user const { user_id } = await req.json() const { data: playStats } = await ctx.supabaseAdmin .from('play_stats') .select() .eq('user_id', user_id) return Response.json(playStats) }), } ``` ### Server-to-server ```ts // A cron job refreshes the "popular this week" list every hour. // Named key ("cron") so it can be rotated without touching other services. export default { fetch: withSupabase({ auth: 'secret:cron' }, async (_req, ctx) => { const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) const { data: popularThisWeek } = await ctx.supabaseAdmin.rpc( 'get_most_favorited_since', { since: oneWeekAgo.toISOString(), limit_count: 10 }, ) await ctx.supabaseAdmin .from('featured_games') .upsert( popularThisWeek.map((g) => ({ game_id: g.id, reason: 'popular' })), ) return Response.json({ popularThisWeek }) }), } ``` The cron job sends the named secret key in the `apikey` header: ```ts const refreshEndpoint = 'https://.supabase.co/functions/v1/refresh-popular' const cronKey = 'sb_secret_...' // the "cron" named secret key await fetch(refreshEndpoint, { method: 'POST', headers: { apikey: cronKey }, }) ``` ## Auth Modes | Mode | Credential | Use case | | ------------------ | ------------------------------- | --------------------------------------------------- | | `"user"` (default) | Valid JWT | Authenticated user endpoints | | `"publishable"` | Valid `default` publishable key | Client-facing, key-validated endpoints | | `"secret"` | Valid `default` secret key | Server-to-server, internal calls | | `"none"` | None | Open endpoints, wrappers that handle their own auth | Array syntax (`auth: ["user", "secret"]`) accepts multiple auth methods — first match wins. An absent credential falls through to the next mode; a present-but-invalid JWT rejects the request (no silent downgrade). Because `"none"` matches every request, it is only accepted on its own or as the last entry of a list (`auth: ["user", "none"]` — optional user). Named key validation: `auth: "publishable:web_app"` or `auth: "secret:automations"` validates against a specific named key in `SUPABASE_PUBLISHABLE_KEYS` or `SUPABASE_SECRET_KEYS`. Bare `auth: "secret"` (or `"publishable"`) matches only the `default` key; use the wildcard `auth: "secret:*"` to accept any key in the set. See [`docs/auth-modes.md`](docs/auth-modes.md). > **Supabase Edge Functions:** By default, the platform requires a valid JWT on every request. If your function uses `auth: 'publishable'`, `auth: 'secret'`, or `auth: 'none'`, disable the platform-level JWT check in `supabase/config.toml`: > > ```toml > [functions.my-function] > verify_jwt = false > ``` ## Context Every handler receives a `SupabaseContext`: ```ts interface SupabaseContext { supabase: SupabaseClient // RLS-scoped (user or anon depending on auth) supabaseAdmin: SupabaseClient // Bypasses RLS userClaims: UserClaims | null // JWT-derived identity (for full User, call supabase.auth.getUser()) jwtClaims: JWTClaims | null // Present when auth is JWT authMode: AuthMode // Which auth mode matched authKeyName?: string // Auth key name of the API key that was used for this request (omitted for `'user'` / `'none'`) } ``` `supabase` is always the safe client — it respects RLS. When `authMode` is `"user"`, it's scoped to that user's permissions. Otherwise, it's initialized as anonymous. `supabaseAdmin` always bypasses RLS. Use it for operations that need full database access. ## Config ```ts withSupabase( { auth: 'user', // who can call this function cors: 'disabled', // disable CORS (default: supabase-js CORS headers) env: { url: '...' }, // env overrides (optional) }, handler, ) ``` `cors` accepts `'default'` (the standard [supabase-js CORS headers](https://supabase.com/docs/guides/functions/cors), also the default), `'disabled'` to disable CORS handling (e.g. when using a framework that handles CORS separately), or `{ headers }` to set custom headers. The boolean (`true`/`false`) and bare `Record` forms are deprecated but still accepted. ```ts withSupabase( { auth: 'user', cors: { headers: { 'Access-Control-Allow-Origin': 'https://myapp.com', 'Access-Control-Allow-Headers': 'authorization, content-type', }, }, }, handler, ) ``` `env` overrides environment variable resolution. Defaults to reading `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEYS`, `SUPABASE_SECRET_KEYS`, and `SUPABASE_JWKS` from the runtime environment. `audience` and `issuer` pin the `aud` and `iss` claims of a `user`-mode token. Each takes a string or an array. A token that lacks the claim, or carries a value outside the list, is rejected with `INVALID_JWT`. Supabase Auth sets `aud` to `authenticated` and `iss` to `https://.supabase.co/auth/v1`, so `issuer: fromSupabaseUrl(url)` limits an endpoint to tokens from one project. ```ts import { fromSupabaseUrl, withSupabase } from '@supabase/server' withSupabase( { auth: 'user', issuer: fromSupabaseUrl('https://.supabase.co'), }, handler, ) ``` Called with config only, `withSupabase(config)` returns an entry for `pipeline` from `@supabase/middleware`. Entries placed after it in the array receive the Supabase context and contribute their own typed keys; entries placed before it run ahead of the auth gate. The first-party entries live on the `@supabase/server/middleware/*` subpaths; see [Postgres](#postgres-rls-scoped-queries). ## Framework Adapters Adapters wrap `withSupabase` for a specific framework's middleware contract. They ship inside `@supabase/server`, so a single `npm install @supabase/server` covers the framework you're using — no separate package per adapter. > [!WARNING] > Adapters are deprecated and will be removed on December 1, 2026. Use [middleware](https://github.com/supabase/middleware) to integrate `@supabase/server` with your framework. > > We're no longer accepting new adapter submissions. > > For Hono, H3, Elysia, NestJS, or TanStack Start, use the framework bridges in [`examples/frameworks`](./examples/frameworks). Read the [framework integration guide](https://supabase.com/docs/reference/server/frameworks) for setup steps. | Framework | Import | Framework version | Docs | | --------- | ---------------------------------- | ---------------------- | -------------------------------------------------- | | Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](docs/adapters/hono.md) | | H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](docs/adapters/h3.md) | | Elysia | `@supabase/server/adapters/elysia` | `^1.4.0` | [docs/adapters/elysia.md](docs/adapters/elysia.md) | | NestJS | `@supabase/server/adapters/nestjs` | `^10.0.0 \|\| ^11.0.0` | [docs/adapters/nestjs.md](docs/adapters/nestjs.md) | See the per-adapter docs above for setup, per-route auth, CORS, error handling, and other patterns. To run `@supabase/middleware` entries inside Hono, H3, Elysia, NestJS, or TanStack Start without an adapter, copy the bridge for your framework from [`examples/frameworks/`](examples/frameworks/). The [Frameworks guide](https://supabase.com/docs/reference/server/frameworks) explains the bridges and how to move off the adapters. ### Elysia ```ts import { Elysia } from 'elysia' import { withSupabase } from '@supabase/server/adapters/elysia' const app = new Elysia() // Protected — plugin resolves supabaseContext before handlers run .use(withSupabase({ auth: 'user' })) .get('/games', async ({ supabaseContext }) => { const { data: myGames } = await supabaseContext.supabase .from('favorite_games') .select() return myGames }) // Public — no plugin means no auth .get('/health', () => ({ status: 'ok' })) app.listen(3000) ``` For per-route auth, use scoped groups: ```ts import { Elysia } from 'elysia' import { withSupabase } from '@supabase/server/adapters/elysia' const app = new Elysia() .get('/health', () => ({ status: 'ok' })) .group('/api', (app) => app .use(withSupabase({ auth: 'user' })) .get('/profile', async ({ supabaseContext }) => { return supabaseContext.userClaims }), ) app.listen(3000) ``` The adapter does not handle CORS — use `@elysiajs/cors` for that. ### NestJS ```ts import { Controller, Get, UseGuards } from '@nestjs/common' import { withSupabase, SupabaseCtx } from '@supabase/server/adapters/nestjs' import type { SupabaseContext } from '@supabase/server' @Controller('games') @UseGuards(withSupabase({ auth: 'user' })) export class GamesController { @Get() list(@SupabaseCtx() ctx: SupabaseContext) { return ctx.supabase.from('favorite_games').select() } } ``` See [docs/adapters/nestjs.md](docs/adapters/nestjs.md) for per-route auth, exception filters, CORS, and more. ## Primitives For when you need more control than `withSupabase` provides — multiple routes with different auth, custom response headers, or building your own wrapper. All primitives are available from `@supabase/server/core`. ```ts import { verifyAuth, createContextClient, createAdminClient, } from '@supabase/server/core' ``` ### verifyAuth Extracts credentials from a Request and validates against the auth config. ```ts const { data: auth, error } = await verifyAuth(req, { auth: 'user' }) if (error) { return Response.json({ message: error.message }, { status: error.status }) } ``` ### verifyCredentials Low-level — works with raw credentials instead of a Request. Used by SSR adapters and custom auth flows. ```ts const credentials = { token: myToken, apikey: null } const { data: result, error } = await verifyCredentials(credentials, { auth: 'user', }) ``` ### createContextClient / createAdminClient ```ts const userScopedClient = createContextClient(auth.token) // RLS applies as this user const anonClient = createContextClient() // RLS applies as anon role const adminClient = createAdminClient() // bypasses RLS entirely ``` ### createSupabaseContext Full context assembly from a Request — `verifyAuth` + client creation in one call. ```ts const { data: ctx, error } = await createSupabaseContext(req, { auth: 'user' }) ``` ### resolveEnv Resolves environment variables with optional overrides. ```ts const { data: env, error } = resolveEnv({ url: process.env.NEXT_PUBLIC_SUPABASE_URL, }) ``` ### Example: custom multi-route handler The same games API and health check from the Hono example, built from primitives instead of a framework: ```ts import { verifyAuth, createContextClient } from '@supabase/server/core' export default { fetch: async (req) => { const url = new URL(req.url) // Public — no auth needed if (url.pathname === '/health') { return Response.json({ status: 'ok' }) } // Protected — verify the JWT, then create a user-scoped client if (url.pathname === '/games') { const { data: result, error } = await verifyAuth(req, { auth: 'user' }) if (error) return Response.json( { message: error.message }, { status: error.status }, ) const userScopedClient = createContextClient(result.token) const { data: myGames } = await userScopedClient .from('favorite_games') .select() return Response.json(myGames) } return new Response('Not found', { status: 404 }) }, } ``` ## Postgres (RLS-scoped queries) When PostgREST isn't the right tool — joins, CTEs, window functions — `withPostgresClient` puts a direct Postgres connection on `ctx.postgres`, scoped to the caller by RLS: ```ts import { pipeline } from '@supabase/middleware' import { withSupabase } from '@supabase/server' import { withPostgresClient } from '@supabase/server/middleware/postgres' export default { fetch: pipeline( [withSupabase({ auth: 'user' }), withPostgresClient()], async (_req, ctx) => { // No WHERE clause — RLS scopes the rows to the caller. const notes = await ctx.postgres.query`select id, body from notes` return Response.json(notes) }, ), } ``` Each query runs in its own transaction that injects the caller's claims and drops to their role, exactly like PostgREST — so `auth.uid()` resolves and your policies enforce. Only `authenticated` and `anon` are assumed; a token naming any other role (including `service_role`, and custom roles) is refused with `code: 'UNSUPPORTED_ROLE'` rather than silently downgraded to `anon`. When a handler legitimately needs to cross user boundaries, `withPostgresAdminClient` is the explicit opt-out — it contributes `ctx.postgresAdmin`, which bypasses RLS and needs no caller identity, so it works under `auth: 'secret'` and `auth: 'none'` too: ```ts import { withPostgresAdminClient } from '@supabase/server/middleware/postgres-admin' pipeline([withSupabase({ auth: 'secret' }), withPostgresAdminClient()], handler) ``` The pair mirrors `ctx.supabase` / `ctx.supabaseAdmin`, and they share one connection pool. Keeping them as two middleware is deliberate: bypassing RLS stays visible at the composition site, so you can grep for every handler that can do it. Needs `pg` installed (optional peer dependency) and a raw TCP socket: Node, Deno, Bun, and the Supabase Edge runtime — **not** Workers-style isolates. Reads `SUPABASE_DB_URL` by default. Remember that `authenticated` also needs table grants, not just policies. See [`docs/postgres.md`](docs/postgres.md) for standalone composition with `withClaims`, the grants requirement, and current limits. ## Environment Variables Automatically available in Supabase Edge Functions: | Variable | Format | Description | | --------------------------- | ------------------------------------------------------------- | -------------------------------------------- | | `SUPABASE_URL` | `https://.supabase.co` | Your project URL | | `SUPABASE_PUBLISHABLE_KEYS` | `{"default":"sb_publishable_...","web":"sb_publishable_..."}` | Publishable API keys (named) | | `SUPABASE_SECRET_KEYS` | `{"default":"sb_secret_...","web":"sb_secret_..."}` | Secret API keys (named) | | `SUPABASE_JWKS` | `{"keys":[...]}` or `[...]` | Inline JSON Web Key Set for JWT verification | Also supported (for local dev, self-hosted, or other runtimes): | Variable | Format | Description | | -------------------------- | -------------------- | --------------------------------------------------------- | | `SUPABASE_PUBLISHABLE_KEY` | `sb_publishable_...` | Single publishable key | | `SUPABASE_SECRET_KEY` | `sb_secret_...` | Single secret key | | `SUPABASE_JWKS_URL` | `https://...` | Remote JWKS endpoint (used when `SUPABASE_JWKS` is unset) | | `SUPABASE_DB_URL` | `postgresql://...` | Postgres connection string, read by `withPostgresClient` | When both singular and plural forms are set, plural takes priority. For other environments, pass overrides via the `env` config option or `resolveEnv()`. See [`docs/environment-variables.md`](docs/environment-variables.md) for details. ## Runtimes `@supabase/server` runs anywhere standard Web `fetch` does — pick the row that matches your deployment target. | Target | Notes | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | **Supabase Edge Functions** | Zero config — environment variables are auto-injected. | | **Vercel Functions** | Edge runtime: `export default { fetch }`. Node runtime: use a [framework adapter](#framework-adapters) or [core primitives](#primitives). | | **Cloudflare Workers** | Enable `nodejs_compat` in `wrangler.toml`, or pass overrides via the `env` config option. | | **Deno / Bun** | Works out of the box via `export default { fetch }`. | | **Node.js** | Use a [framework adapter](#framework-adapters) or [core primitives](#primitives) with your framework of choice. | Using a framework? See [Framework Adapters](#framework-adapters) for Hono, H3 / Nuxt, and Elysia, or [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) for Next.js / SvelteKit / Remix (compose with [`@supabase/ssr`](https://github.com/supabase/ssr)). ### Does this replace `@supabase/ssr`? No. `@supabase/ssr` handles cookie-based session management for frameworks like Next.js and SvelteKit. `@supabase/server` handles stateless, header-based auth for Edge Functions, Workers, and other backend runtimes. The composable primitives already work in SSR environments but require more setup — see [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) for the Next.js example. The two packages coexist and are not replacements for each other. Deeper integration with `@supabase/ssr` is on the roadmap. ## Exports | Export | What's in it | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `@supabase/server` | `withSupabase`, `createSupabaseContext` | | `@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `createContextClient`, `createAdminClient`, `resolveEnv` | | `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) | | `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) | | `@supabase/server/adapters/elysia` | `withSupabase` (Elysia plugin) | | `@supabase/server/adapters/nestjs` | `withSupabase` (NestJS guard), `SupabaseCtx` (param decorator) | | `@supabase/server/middleware/client` | `withSupabaseClient` (RLS-scoped `ctx.supabase` client) | | `@supabase/server/middleware/admin-client` | `withSupabaseAdminClient` (`ctx.supabaseAdmin`, bypasses RLS) | | `@supabase/server/middleware/claims` | `withClaims` (JWKS-verified `ctx.jwtClaims`) | | `@supabase/server/middleware/required-claims` | `withRequiredClaims` (user-mode auth gate, non-null `ctx.jwtClaims`) | | `@supabase/server/middleware/postgres` | `withPostgresClient` (RLS-scoped `ctx.postgres` client) | | `@supabase/server/middleware/postgres-admin` | `withPostgresAdminClient` (`ctx.postgresAdmin`, bypasses RLS) | | `@supabase/server/oauth-protected-resource` | `withOAuthProtectedResource`, `fromSupabaseUrl`, `resourceMetadataResponse`, `unauthorizedResponse`; see [`docs/mcp.md`](docs/mcp.md) | | `@supabase/server/peer/supabase-js` | Re-exported `supabase-js` types (`SupabaseClient`, `PostgrestError`, …) | ## Documentation | Question | Doc file | | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | | How do I create a basic endpoint? | [`docs/getting-started.md`](docs/getting-started.md) | | What auth modes are available? Array syntax? Named keys? | [`docs/auth-modes.md`](docs/auth-modes.md) | | Which framework adapters exist, and what replaces them? | [Framework Adapters](#framework-adapters) | | How do I use this with Hono? | [`docs/adapters/hono.md`](docs/adapters/hono.md) | | How do I use this with H3 / Nuxt? | [`docs/adapters/h3.md`](docs/adapters/h3.md) | | How do I use this with Elysia? | [`docs/adapters/elysia.md`](docs/adapters/elysia.md) | | How do I use this with NestJS? | [`docs/adapters/nestjs.md`](docs/adapters/nestjs.md) | | How do I use low-level primitives for custom flows? | [`docs/core-primitives.md`](docs/core-primitives.md) | | How do environment variables work across runtimes? | [`docs/environment-variables.md`](docs/environment-variables.md) | | How do I handle errors? What codes exist? | [`docs/error-handling.md`](docs/error-handling.md) | | How do I get typed database queries? | [`docs/typescript-generics.md`](docs/typescript-generics.md) | | How do I run raw SQL scoped to the caller by RLS? | [`docs/postgres.md`](docs/postgres.md) | | Why does a Postgres query hang, return zero rows, or get my project blocked at the pooler? | [`docs/postgres.md#troubleshooting`](docs/postgres.md#troubleshooting) | | How do I use this with `@supabase/ssr` (Next.js, SvelteKit, Remix)? | [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) | | How do I build an MCP server my users connect to (OAuth discovery, RLS-scoped tools)? | [`docs/mcp.md`](docs/mcp.md) | | What's the complete API surface? | [`docs/api-reference.md`](docs/api-reference.md) | | Does this library support legacy API keys or HS256 JWTs? | [`docs/auth-modes.md`](docs/auth-modes.md#legacy-keys-and-jwts-are-not-supported) | ## Development ```bash pnpm install pnpm dev ``` ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, commit conventions, and release process. ## License MIT