Files
supabase__server/README.md
T
Tomás Pozo 18818e0b5d Pre-v1 API cleanup: rename allow/authType/claims, narrow authKeyName, refresh adapter docs (#48)
* feat: rename `allow` config option to `auth`

Aligns the SDK with Supabase CLI terminology — `auth: 'user'` reads
more naturally than `allow: 'user'`. The legacy `allow` key still
works (with a one-time `console.warn` per process) and will be
removed in a future major release; when both `auth` and `allow` are
provided, `auth` wins. Also exports new `AuthMode` / `AuthModeWithKey`
types alongside deprecated `Allow` / `AllowWithKey` aliases.

* feat!: rename auth mode values `'always'` → `'none'` and `'public'` → `'publishable'`

Aligns auth-mode values with Supabase CLI terminology. `'none'` reads more
directly than `'always'` for "no authentication required", and
`'publishable'` matches the `SUPABASE_PUBLISHABLE_KEY(S)` env var names.
`'secret'` and `'user'` are unchanged.

BREAKING CHANGE: the `'always'` and `'public'` mode values no longer work.
Replace `auth: 'always'` with `auth: 'none'`, `auth: 'public'` with
`auth: 'publishable'`, and `auth: 'public:<name>'` with
`auth: 'publishable:<name>'`. Runtime checks like
`ctx.authType === 'public'` must be updated to
`ctx.authType === 'publishable'`.

* feat!: rename `authType` field to `authMode` on `AuthResult` and `SupabaseContext`

Lines the field name up with its type — `authMode: AuthMode`. Reads more
naturally for both humans and AI agents working with the API.

BREAKING CHANGE: the `authType` field was renamed to `authMode` on
`AuthResult` (returned by `verifyAuth` / `verifyCredentials`) and on
`SupabaseContext` (passed to handlers). Find-and-replace
`ctx.authType` → `ctx.authMode` and `auth.authType` → `auth.authMode`
across your codebase.

* feat!: rename `claims` field to `jwtClaims` on `AuthResult` and `SupabaseContext`

Pairs naturally with `userClaims` and makes the snake_case JWT payload
distinct from the normalized identity view at a glance.

* refactor!: narrow `SupabaseContext.authKeyName` to `string | undefined`

The field used to be `string | null | undefined` (optional + explicitly
nullable), forcing consumers to handle two absence values. Collapse to a
single representation by dropping `null`: the property is simply omitted
for `'user'` and `'none'` modes, which don't match a named key.

`AuthResult.keyName` keeps its `string | null` shape — it's the
low-level type where the field is always present and `null` actively
signals "no named key for this mode."

* docs: add publishable-key example to README quick start

The auth-modes table documented the publishable mode but the quick start
only showed user, none, secret, dual, and server-to-server examples,
leaving readers without a concrete shape for publishable. Slot it
between the "no auth" and "secret" examples so the progression reads
no key → publishable (anon, key-gated) → secret (admin, key-gated).

The example clarifies the resulting client behavior — `supabase` is
anonymous, RLS still applies, and the publishable key is a client gate
rather than a user identity — which is the most common point of
confusion vs. `auth: 'secret'`.

* docs: update skill description

* docs: update ssr references accross docs and skill

* docs(adapters): add ecosystem index + community contribution guide

Adds src/adapters/README.md (index, maintenance model, contribution
checklist) and docs/adapters/h3.md. Moves docs/hono-adapter.md into
docs/adapters/. Slims the top-level README Framework Adapters section
to a canonical adapter table + brief examples. Updates CONTRIBUTING.md,
docs/getting-started.md, and the supabase-server skill to reference the
new paths.

* docs: sweep adapter and SSR docs to use renamed API

The cherry-picked docs commits were authored before the API renames in
this branch, so the new content arrived using `allow:`, `'always'`,
`'public'`, `claims`, `authType`, and `AllowWithKey`. Update the
newly-arrived files in line with the renamed API:

- docs/adapters/h3.md — `allow:` → `auth:`, `claims, authType` →
  `jwtClaims, authMode` throughout
- docs/ssr-frameworks.md — composed Next.js adapter example now uses
  `auth:` / `AuthModeWithKey` / `jwtClaims` / `authMode`
- src/adapters/README.md — adapter-test checklist mentions the four
  current modes (`'user'`, `'publishable'`, `'secret'`, `'none'`)
- CONTRIBUTING.md — same wording fix in the adapter-PR section
- skills/supabase-server/SKILL.md — top-level skill description points
  at `auth:` and the new mode values; legacy patterns folded into the
  existing migration trigger
- src/adapters/hono/middleware.ts — inline comment example uses `auth:`
  in both halves rather than mixing legacy and current option names
- README.md — collapsed Hono and H3 quick-start snippets use `auth:`

Migration prose (`README.md` callout, `docs/auth-modes.md` callout,
`docs/api-reference.md` deprecated-aliases section, `SKILL.md`
migration callouts) intentionally still references the old names; they
document the migration itself.

* docs: reframe Beta disclaimer for v1 launch + extract MIGRATION.md

The Beta callout ("APIs and documentation may change") directly
contradicts the SemVer commitment that v1 makes. For launch material
that pins to v1, the contradiction undermines the stability message
the version number is meant to carry.

Replace it with a v1.0 callout that leads with stability under SemVer
and follows with honest "active development continues" framing — new
adapters and ergonomic improvements in minor releases, breaking
changes only ever in a major bump.

Move the v0 → v1 rename map out of the README and into a dedicated
MIGRATION.md. The README quick start was buried under 20+ lines of
migration tables that only matter to upgraders, not first-time
readers — exactly the wrong tradeoff at launch. New short callout
points upgraders at MIGRATION.md.

SKILL.md gets the same Beta → v1.0 swap. The agent-operational
migration rules (lines 12-14: "always emit `auth:` in new code", "the
new mode values are `'none'` / `'publishable'`") are kept inline —
they're rules the agent applies every time it writes code, not
user-facing migration steps, so they don't belong in MIGRATION.md.

* docs: reframe v1.0 callout as "Public Beta" to match Supabase house style

The previous "Stable under SemVer; active development continues" framing
mixed two distinct axes — code stability (SemVer) and product
lifecycle stage (Public Beta / GA) — into the SemVer line. Several
Supabase docs run those independently: a release can be v1+ in SemVer
terms and still labeled Public Beta in lifecycle terms.

Lead with both signals in the headline: "v1.0 — Public Beta." Keep the
SemVer commitment ("breaking changes only ship as a major bump") so
launch copy can pin to v1, and pair it with the Public Beta lifecycle
stage so readers know the product line is still early. Same swap in
the SKILL.md mirror.
2026-05-06 12:16:14 +03:00

20 KiB

@supabase/server

License Package pkg.pr.new Docs

v1.0 — 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.x release? 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'

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

# Deno / Supabase Edge Functions (no install — import directly)
import { withSupabase } from "npm:@supabase/server";

# 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:

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

// 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)

// 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

// The mobile app browses the game catalog before the user signs in.
// auth: 'publishable' validates the apikey header against a 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:

const catalogEndpoint = 'https://<project>.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

// An admin dashboard fetches the list of featured games to curate.
// Secret key auth (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)

// 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

// 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:

const refreshEndpoint =
  'https://<project>.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 publishable key Client-facing, key-validated endpoints
"secret" Valid 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). See docs/auth-modes.md.

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.

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:

[functions.my-function]
verify_jwt = false

Context

Every handler receives a SupabaseContext:

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

withSupabase(
  {
    auth: 'user', // who can call this function
    cors: false, // disable CORS (default: supabase-js CORS headers)
    env: { url: '...' }, // env overrides (optional)
  },
  handler,
)

cors defaults to the standard supabase-js CORS headers. Pass a Record<string, string> to set custom headers, or false to disable CORS handling (e.g. when using a framework that handles CORS separately).

withSupabase(
  {
    auth: 'user',
    cors: {
      '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.

Framework Adapters

Adapters wrap withSupabase for a specific framework's middleware contract. All adapters are community-maintained — both Hono and H3 originated as community contributions. They live in this repo and ship with the core package, so a single npm install @supabase/server covers the framework you're using. See src/adapters/README.md for the maintenance model and the requirements for contributing a new adapter.

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

Hono

import { Hono } from 'hono'
import { withSupabase } from '@supabase/server/adapters/hono'

const app = new Hono()
app.use('*', withSupabase({ auth: 'user' }))

export default { fetch: app.fetch }

See docs/adapters/hono.md for per-route auth, CORS, error handling, and other patterns.

H3 / Nuxt

import { H3 } from 'h3'
import { withSupabase } from '@supabase/server/adapters/h3'

const app = new H3()
app.use(withSupabase({ auth: 'user' }))

export default { fetch: app.fetch }

See docs/adapters/h3.md for per-route auth, Nuxt server-middleware patterns, 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.

import {
  verifyAuth,
  createContextClient,
  createAdminClient,
} from '@supabase/server/core'

verifyAuth

Extracts credentials from a Request and validates against the auth config.

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.

const credentials = { token: myToken, apikey: null }
const { data: result, error } = await verifyCredentials(credentials, {
  auth: 'user',
})

createContextClient / createAdminClient

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.

const { data: ctx, error } = await createSupabaseContext(req, { auth: 'user' })

resolveEnv

Resolves environment variables with optional overrides.

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:

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 })
  },
}

Environment Variables

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 [...] 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

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.

Runtimes

  • Supabase Edge Functions — environment variables are auto-injected. Zero config.
  • Deno / Bun — works out of the box with the export default { fetch } pattern.
  • Node.js — use the Hono adapter, H3 adapter, or core primitives with your framework of choice.
  • Cloudflare Workers — enable nodejs_compat in wrangler.toml or pass env overrides via the env config option.
  • Nuxt — use the H3 adapter directly as a server middleware.
  • Next.js / SvelteKit / Remix — compose with @supabase/ssr: @supabase/ssr owns cookies + refresh-token rotation, @supabase/server adds verified claims and typed RLS / admin clients on top. See docs/ssr-frameworks.md.

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 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)

Documentation

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 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 use this with @supabase/ssr (Next.js, SvelteKit, Remix)? docs/ssr-frameworks.md
What's the complete API surface? docs/api-reference.md

Development

pnpm install
pnpm dev

Contributing

See CONTRIBUTING.md for development workflow, commit conventions, and release process.

License

MIT