v1.X — Public Beta. First stable release under SemVer: breaking changes only ship as a major bump. The package is still early — expect new adapters, ergonomic improvements, and features to land frequently in minor releases. Found a rough edge? Open an issue or submit a PR.
Coming from a
0.xrelease? See 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, including client
creation and authentication automatically scoped to the inbound requests to your
Edge Functions and APIs.
import{withSupabase}from'@supabase/server'exportdefault{fetch: withSupabase({auth: 'user'},async(_req,ctx)=>{// RLS-scoped — this user only sees their own favoritesconst{data: myGames}=awaitctx.supabase.from('favorite_games').select()returnResponse.json(myGames)}),}One import. One line of config. Auth is validated, clients are ready, CORS is handled. Your handler only runs on successful auth.
# Deno / Supabase Edge Functions (no install — import directly)
import { withSupabase } from "npm:@supabase/server";# npm
npm install @supabase/server
# pnpm
pnpm add @supabase/serverInstall the skill so your AI coding agent (Claude Code, Cursor, etc.) knows how to use this package:
npx skills add supabase/serverImagine 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:
// A signed-in user fetches their favorite games.exportdefault{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 favoritesconst{data: myGames}=awaitsupabase.from('favorite_games').select()returnResponse.json(myGames)}),}// The frontend hits this before showing the login screen.// auth: 'none' means no credentials required.exportdefault{fetch: withSupabase({auth: 'none'},async(_req,_ctx)=>{returnResponse.json({status: 'ok'})}),}// 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.exportdefault{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}=awaitctx.supabase.from('games').select('id, name, cover_url')returnResponse.json(catalog)}),}The mobile app sends the publishable key in the apikey header:
constcatalogEndpoint='https://<project>.supabase.co/functions/v1/catalog'constpublishableKey='sb_publishable_...'awaitfetch(catalogEndpoint,{headers: {apikey: publishableKey}})Unlike
auth: 'secret', thesupabaseclient 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.
// 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.exportdefault{fetch: withSupabase({auth: 'secret'},async(_req,ctx)=>{const{data: featuredGames}=awaitctx.supabaseAdmin.from('featured_games').select()returnResponse.json(featuredGames)}),}// Users view their own play stats from the app (JWT).// A backend service pulls stats for any user (secret key + user_id in body).exportdefault{fetch: withSupabase({auth: ['user','secret']},async(req,ctx)=>{constcallerIsUser=ctx.authMode==='user'if(callerIsUser){// RLS-scoped — the database enforces "own stats only"const{data: myStats}=awaitctx.supabase.from('play_stats').select()returnResponse.json(myStats)}// Service path — bypass RLS to pull stats for any userconst{ user_id }=awaitreq.json()const{data: playStats}=awaitctx.supabaseAdmin.from('play_stats').select().eq('user_id',user_id)returnResponse.json(playStats)}),}// A cron job refreshes the "popular this week" list every hour.// Named key ("cron") so it can be rotated without touching other services.exportdefault{fetch: withSupabase({auth: 'secret:cron'},async(_req,ctx)=>{constoneWeekAgo=newDate(Date.now()-7*24*60*60*1000)const{data: popularThisWeek}=awaitctx.supabaseAdmin.rpc('get_most_favorited_since',{since: oneWeekAgo.toISOString(),limit_count: 10},)awaitctx.supabaseAdmin.from('featured_games').upsert(popularThisWeek.map((g)=>({game_id: g.id,reason: 'popular'})),)returnResponse.json({ popularThisWeek })}),}The cron job sends the named secret key in the apikey header:
constrefreshEndpoint='https://<project>.supabase.co/functions/v1/refresh-popular'constcronKey='sb_secret_...'// the "cron" named secret keyawaitfetch(refreshEndpoint,{method: 'POST',headers: {apikey: cronKey},})| 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).
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.
Supabase Edge Functions: By default, the platform requires a valid JWT on every request. If your function uses
auth: 'publishable',auth: 'secret', orauth: 'none', disable the platform-level JWT check insupabase/config.toml:[functions.my-function] verify_jwt = false
Every handler receives a SupabaseContext:
interfaceSupabaseContext{supabase: SupabaseClient// RLS-scoped (user or anon depending on auth)supabaseAdmin: SupabaseClient// Bypasses RLSuserClaims: UserClaims|null// JWT-derived identity (for full User, call supabase.auth.getUser())jwtClaims: JWTClaims|null// Present when auth is JWTauthMode: AuthMode// Which auth mode matchedauthKeyName?: 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.
withSupabase({auth: 'user',// who can call this functioncors: 'disabled',// disable CORS (default: supabase-js CORS headers)env: {url: '...'},// env overrides (optional)},handler,)cors accepts 'default' (the standard supabase-js CORS headers, 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<string, string> forms are deprecated but still accepted.
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.
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.
Adapters are a community-driven initiative. They're developed, maintained, and evolved by contributors — including responding to upstream framework changes. See
src/adapters/README.mdfor the contribution requirements (tests, types, docs, build wiring) if you'd like to add or help maintain one.
| Framework | Import | Framework version | Docs |
|---|---|---|---|
| Hono | @supabase/server/adapters/hono | ^4.0.0 | docs/adapters/hono.md |
| H3 / Nuxt | @supabase/server/adapters/h3 | ^2.0.0 | docs/adapters/h3.md |
| Elysia | @supabase/server/adapters/elysia | ^1.4.0 | docs/adapters/elysia.md |
| NestJS | @supabase/server/adapters/nestjs | ^10.0.0 || ^11.0.0 | docs/adapters/nestjs.md |
See the per-adapter docs above for setup, per-route auth, CORS, error handling, and other patterns.
import{Elysia}from'elysia'import{withSupabase}from'@supabase/server/adapters/elysia'constapp=newElysia()// Protected — plugin resolves supabaseContext before handlers run.use(withSupabase({auth: 'user'})).get('/games',async({ supabaseContext })=>{const{data: myGames}=awaitsupabaseContext.supabase.from('favorite_games').select()returnmyGames})// Public — no plugin means no auth.get('/health',()=>({status: 'ok'}))app.listen(3000)For per-route auth, use scoped groups:
import{Elysia}from'elysia'import{withSupabase}from'@supabase/server/adapters/elysia'constapp=newElysia().get('/health',()=>({status: 'ok'})).group('/api',(app)=>app.use(withSupabase({auth: 'user'})).get('/profile',async({ supabaseContext })=>{returnsupabaseContext.userClaims}),)app.listen(3000)The adapter does not handle CORS — use @elysiajs/cors for that.
import{Controller,Get,UseGuards}from'@nestjs/common'import{withSupabase,SupabaseCtx}from'@supabase/server/adapters/nestjs'importtype{SupabaseContext}from'@supabase/server'
@Controller('games')
@UseGuards(withSupabase({auth: 'user'}))exportclassGamesController{
@Get()list(@SupabaseCtx()ctx: SupabaseContext){returnctx.supabase.from('favorite_games').select()}}See docs/adapters/nestjs.md for per-route auth, exception filters, CORS, and more.
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.
import{verifyAuth,createContextClient,createAdminClient,}from'@supabase/server/core'Extracts credentials from a Request and validates against the auth config.
const{data: auth, error }=awaitverifyAuth(req,{auth: 'user'})if(error){returnResponse.json({message: error.message},{status: error.status})}Low-level — works with raw credentials instead of a Request. Used by SSR adapters and custom auth flows.
constcredentials={token: myToken,apikey: null}const{data: result, error }=awaitverifyCredentials(credentials,{auth: 'user',})constuserScopedClient=createContextClient(auth.token)// RLS applies as this userconstanonClient=createContextClient()// RLS applies as anon roleconstadminClient=createAdminClient()// bypasses RLS entirelyFull context assembly from a Request — verifyAuth + client creation in one call.
const{data: ctx, error }=awaitcreateSupabaseContext(req,{auth: 'user'})Resolves environment variables with optional overrides.
const{data: env, error }=resolveEnv({url: process.env.NEXT_PUBLIC_SUPABASE_URL,})The same games API and health check from the Hono example, built from primitives instead of a framework:
import{verifyAuth,createContextClient}from'@supabase/server/core'exportdefault{fetch: async(req)=>{consturl=newURL(req.url)// Public — no auth neededif(url.pathname==='/health'){returnResponse.json({status: 'ok'})}// Protected — verify the JWT, then create a user-scoped clientif(url.pathname==='/games'){const{data: result, error }=awaitverifyAuth(req,{auth: 'user'})if(error)returnResponse.json({message: error.message},{status: error.status},)constuserScopedClient=createContextClient(result.token)const{data: myGames}=awaituserScopedClient.from('favorite_games').select()returnResponse.json(myGames)}returnnewResponse('Not found',{status: 404})},}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:
import{withSupabase}from'@supabase/server'import{withPostgresClient}from'@supabase/server/middleware/postgres'exportdefault{fetch: withSupabase({auth: 'user',middleware: [withPostgresClient()]},async(_req,ctx)=>{// No WHERE clause — RLS scopes the rows to the caller.constnotes=awaitctx.postgres.query`select id, body from notes`returnResponse.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:
import{withPostgresAdminClient}from'@supabase/server/middleware/postgres-admin'withSupabase({auth: 'secret',middleware: [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 for standalone composition with withClaims, the grants requirement, and current limits.
Automatically available in Supabase Edge Functions:
| Variable | Format | Description |
|---|---|---|
SUPABASE_URL | https://<ref>.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 for details.
@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 or core 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 or core primitives with your framework of choice. |
Using a framework? See Framework Adapters for Hono, H3 / Nuxt, and Elysia, or docs/ssr-frameworks.md for Next.js / SvelteKit / Remix (compose with @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 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.
| 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 |
@supabase/server/peer/supabase-js | Re-exported supabase-js types (SupabaseClient, PostgrestError, …) |
| Question | Doc file |
|---|---|
| How do I create a basic endpoint? | docs/getting-started.md |
| What auth modes are available? Array syntax? Named keys? | docs/auth-modes.md |
| Which framework adapters exist? How do I contribute one? | src/adapters/README.md |
| How do I use this with Hono? | docs/adapters/hono.md |
| How do I use this with H3 / Nuxt? | docs/adapters/h3.md |
| How do I use this with Elysia? | docs/adapters/elysia.md |
| How do I use this with NestJS? | docs/adapters/nestjs.md |
| How do I use low-level primitives for custom flows? | docs/core-primitives.md |
| How do environment variables work across runtimes? | docs/environment-variables.md |
| How do I handle errors? What codes exist? | docs/error-handling.md |
| How do I get typed database queries? | docs/typescript-generics.md |
| How do I run raw SQL scoped to the caller by RLS? | docs/postgres.md |
How do I use this with @supabase/ssr (Next.js, SvelteKit, Remix)? | docs/ssr-frameworks.md |
| What's the complete API surface? | docs/api-reference.md |
pnpm install
pnpm devSee CONTRIBUTING.md for development workflow, commit conventions, and release process.
MIT