Files
supabase__server/docs/auth-modes.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

8.2 KiB

Auth Modes

Overview

Every request is validated against one or more auth modes before your handler runs. The auth config determines which modes are accepted.

allow is deprecated. The auth option replaces the legacy allow option. allow still works (with a one-time console.warn) but will be removed in a future major release. Migration is a find-and-replace: allow:auth:.

Breaking — auth API renamed. 'always' is now 'none' and 'public' is now 'publishable' (including the colon variants 'public:<name>''publishable:<name>'). The field on AuthResult and SupabaseContext was also renamed from authType to authMode so it matches the AuthMode type. The old names no longer work — update the option values you pass in and any runtime checks on ctx.authType (now ctx.authMode).

Mode Credential required Typical use case
'user' Valid JWT in Authorization: Bearer <token> Authenticated user endpoints
'publishable' Valid publishable key in apikey header Client-facing, key-validated endpoints
'secret' Valid secret key in apikey header Server-to-server, internal calls
'none' None Open endpoints, custom auth wrappers

Supabase Edge Functions: By default, the platform requires a valid JWT on every request same as 'user'. If your function uses 'publishable', 'secret' or 'none', disable the platform-level JWT check in supabase/config.toml:

[functions.my-function]
verify_jwt = false

User mode

The default. Verifies the JWT using your project's JWKS (JSON Web Key Set).

import { withSupabase } from '@supabase/server'

export default {
  fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => {
    // ctx.userClaims has the caller's identity
    console.log(ctx.userClaims!.id) // "d0f1a2b3-..."
    console.log(ctx.userClaims!.email) // "user@example.com"
    console.log(ctx.userClaims!.role) // "authenticated"

    // ctx.jwtClaims has the raw JWT payload
    console.log(ctx.jwtClaims!.sub) // same as userClaims.id
    console.log(ctx.jwtClaims!.exp) // token expiration (epoch seconds)

    // ctx.supabase is scoped to this user — RLS applies
    const { data } = await ctx.supabase.from('todos').select()
    return Response.json(data)
  }),
}

The caller must send:

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

userClaims vs supabase.auth.getUser(): userClaims is extracted from the JWT and is available instantly — no network call. It includes id, email, role, appMetadata, and userMetadata. For the full Supabase User object (email confirmation status, providers, linked identities), call ctx.supabase.auth.getUser(), which makes a request to the auth server.

Publishable mode

Validates that the apikey header contains a recognized publishable key. Uses timing-safe comparison to prevent timing attacks. See security.md for details.

import { withSupabase } from '@supabase/server'

export default {
  fetch: withSupabase({ auth: 'publishable' }, async (_req, ctx) => {
    // ctx.userClaims is null — no JWT involved
    // ctx.supabase is initialized as anonymous (RLS anon role)
    const { data } = await ctx.supabase.from('products').select()
    return Response.json(data)
  }),
}

The caller must send:

apikey: sb_publishable_abc123...

By default, publishable mode validates against the "default" key in SUPABASE_PUBLISHABLE_KEYS. Use named key syntax to target a specific key (see below).

Secret mode

Validates that the apikey header contains a recognized secret key. Same timing-safe comparison as publishable mode. See security.md for details.

import { withSupabase } from '@supabase/server'

export default {
  fetch: withSupabase({ auth: 'secret' }, async (_req, ctx) => {
    // ctx.supabaseAdmin bypasses RLS — use for privileged operations
    const { data } = await ctx.supabaseAdmin.from('config').select()
    return Response.json(data)
  }),
}

The caller must send:

apikey: sb_secret_xyz789...

None mode

No credentials required. Every request is accepted.

import { withSupabase } from '@supabase/server'

export default {
  fetch: withSupabase({ auth: 'none' }, async (_req, ctx) => {
    // ctx.authMode is 'none'
    // ctx.userClaims is null
    // ctx.supabase is anonymous (RLS anon role)
    return Response.json({ status: 'healthy' })
  }),
}

Use none for health checks, public APIs, or when you handle auth yourself inside the handler.

Array syntax (multiple modes)

Accept multiple auth methods. Modes are tried in order — the first match wins.

import { withSupabase } from '@supabase/server'

export default {
  fetch: withSupabase({ auth: ['user', 'secret'] }, async (req, ctx) => {
    // ctx.authMode tells you which mode matched
    if (ctx.authMode === 'user') {
      // Called by an authenticated user
      const { data } = await ctx.supabase.from('reports').select()
      return Response.json(data)
    }

    // Called by another service with a secret key
    const { user_id } = await req.json()
    const { data } = await ctx.supabaseAdmin
      .from('reports')
      .select()
      .eq('user_id', user_id)
    return Response.json(data)
  }),
}

A request with a valid JWT matches 'user'. A request with a valid secret key matches 'secret'. A request with neither is rejected.

Fallthrough vs rejection. A mode is only "tried" when its credential is actually present. A request with no Authorization header moves on to the next mode. But if a JWT is present and fails verification (malformed, expired, wrong signature, or missing a sub claim), the request is rejected immediately with InvalidCredentialsError — it will not silently fall through to 'publishable', 'secret', or 'none'. The same rule applies on the API-key side: 'publishable' and 'secret' fall through only when no apikey header is sent. This prevents a bad credential from being downgraded to a less-privileged auth mode.

Named key syntax

When your project has multiple API keys (e.g., separate keys for web, mobile, and internal services), use the colon syntax to validate against a specific named key.

Keys are stored as a JSON object in SUPABASE_PUBLISHABLE_KEYS or SUPABASE_SECRET_KEYS:

{
  "default": "sb_publishable_123...",
  "web": "sb_publishable_abc...",
  "mobile": "sb_publishable_a1b2..."
}

Target a specific key

// Only accept the "web" publishable key
withSupabase({ auth: 'publishable:web' }, handler)

// Only accept the "internal" secret key
withSupabase({ auth: 'secret:internal' }, handler)

Wildcard — accept any key in the set

// Accept any publishable key
withSupabase({ auth: 'publishable:*' }, handler)

// Accept any secret key
withSupabase({ auth: 'secret:*' }, handler)

Which key matched?

When using named keys, ctx.authMode tells you the mode and keyName on the AuthResult (from core primitives) tells you which key matched. In the high-level withSupabase wrapper, the matched key is used internally for client creation.

Combining named keys with other modes

withSupabase({ auth: ['user', 'publishable:web'] }, async (_req, ctx) => {
  // Accepts either a valid JWT or the "web" publishable key
  return Response.json({ authMode: ctx.authMode })
})

How auth flows through the system

  1. extractCredentials(request) reads Authorization: Bearer <token> and apikey from headers
  2. Each mode in auth is tried in order against the extracted credentials
  3. First match wins — returns an AuthResult with authMode, token, userClaims, jwtClaims, and keyName. A mode falls through to the next only when its credential is absent; a credential that is present but invalid terminates the chain with InvalidCredentialsError.
  4. The auth result is used to create scoped clients (supabase with the user's token, supabaseAdmin with the secret key)
  5. Everything is bundled into a SupabaseContext and passed to your handler