Files
supabase__server/docs/auth-modes.md
T
Ismail Pelaseyed 0251690a7f fix!: reject invalid JWTs immediately instead of falling through to next auth mode (#35)
* fix: reject invalid JWTs immediately instead of falling through to next auth mode

Introduce an INVALID sentinel so tryMode can distinguish "credential
present but failed" from "credential absent." The main loop now short-
circuits on INVALID instead of silently trying the next allowed mode.

Also covers the case where a JWT verifies cryptographically but has no
sub claim (or sub isn't a string) -- previously returned null (fallthrough),
now returns INVALID (reject).

BREAKING CHANGE: when multiple auth modes are allowed, a present-but-invalid
JWT is now rejected with InvalidCredentialsError instead of falling through
to the next mode. Clients that previously relied on silent fallthrough
(e.g., stale token + valid apikey) must now either omit the Authorization
header or refresh the token.

* docs: clarify invalid-JWT no-fallthrough semantics

Align documentation with the behavior introduced in the fix!: commit on
this branch. Make clear across user-facing docs, TSDoc, and SKILL that:

- A mode is "tried" only when its credential is actually present, so a
  request with no Authorization header still falls through.
- A JWT that is present but fails verification (malformed, expired, wrong
  signature, missing sub) rejects with InvalidCredentialsError — it does
  not silently fall through to another allowed mode.

Touches README, docs/auth-modes, docs/security, docs/error-handling,
docs/api-reference, skills/supabase-server/SKILL, and TSDoc on the Allow
type, WithSupabaseConfig.allow, and verifyCredentials.

---------

Co-authored-by: Tomas Pozo <tomaspozogarzon@gmail.com>
2026-04-22 19:22:48 -05:00

7.5 KiB

Auth Modes

Overview

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

Mode Credential required Typical use case
'user' Valid JWT in Authorization: Bearer <token> Authenticated user endpoints
'public' Valid publishable key in apikey header Client-facing, key-validated endpoints
'secret' Valid secret key in apikey header Server-to-server, internal calls
'always' 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 'public', 'secret' or 'always', 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({ allow: '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.claims has the raw JWT payload
    console.log(ctx.claims!.sub) // same as userClaims.id
    console.log(ctx.claims!.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.

Public 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({ allow: 'public' }, 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, public 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 public mode. See security.md for details.

import { withSupabase } from '@supabase/server'

export default {
  fetch: withSupabase({ allow: '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...

Always mode

No credentials required. Every request is accepted.

import { withSupabase } from '@supabase/server'

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

Use always 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({ allow: ['user', 'secret'] }, async (req, ctx) => {
    // ctx.authType tells you which mode matched
    if (ctx.authType === '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 'public', 'secret', or 'always'. The same rule applies on the API-key side: 'public' 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({ allow: 'public:web' }, handler)

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

Wildcard — accept any key in the set

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

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

Which key matched?

When using named keys, ctx.authType 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({ allow: ['user', 'public:web'] }, async (_req, ctx) => {
  // Accepts either a valid JWT or the "web" publishable key
  return Response.json({ authType: ctx.authType })
})

How auth flows through the system

  1. extractCredentials(request) reads Authorization: Bearer <token> and apikey from headers
  2. Each mode in allow is tried in order against the extracted credentials
  3. First match wins — returns an AuthResult with authType, token, userClaims, claims, 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