Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ export default {
// 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) => {
// Key-based modes go before 'user' (see auth-mode ordering note below).
fetch: withSupabase({ auth: ['secret', 'user'] }, async (req, ctx) => {
const callerIsUser = ctx.authMode === 'user'

if (callerIsUser) {
Expand Down Expand Up @@ -200,7 +201,7 @@ await fetch(refreshEndpoint, {
| `"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`](docs/auth-modes.md).
Array syntax (`auth: ["secret", "user"]`) 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). **List key-based modes (`"secret"`/`"publishable"`) before `"user"`** — behind Supabase's API gateway an `apikey` request that omits `Authorization` still arrives with a gateway-injected bearer token, so a `"user"` mode placed first would try to verify it and reject before the key-based mode is reached. See [`docs/auth-modes.md`](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`.

Expand Down
2 changes: 1 addition & 1 deletion docs/adapters/h3.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ app.post('/admin/sync', withSupabase({ auth: 'secret' }), async (event) => {
// Dual auth — users or services
app.get(
'/reports',
withSupabase({ auth: ['user', 'secret'] }),
withSupabase({ auth: ['secret', 'user'] }),
async (event) => {
const { authMode } = event.context.supabaseContext
return { authMode }
Expand Down
2 changes: 1 addition & 1 deletion docs/adapters/hono.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ app.post('/admin/sync', withSupabase({ auth: 'secret' }), async (c) => {
})

// Dual auth — users or services
app.get('/reports', withSupabase({ auth: ['user', 'secret'] }), async (c) => {
app.get('/reports', withSupabase({ auth: ['secret', 'user'] }), async (c) => {
const { supabase, authMode } = c.var.supabaseContext
return c.json({ authMode })
})
Expand Down
2 changes: 1 addition & 1 deletion docs/adapters/nestjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export class AppController {

// Dual auth — users or services
@Get('reports')
@UseGuards(withSupabase({ auth: ['user', 'secret'] }))
@UseGuards(withSupabase({ auth: ['secret', 'user'] }))
reports(@SupabaseCtx('authMode') authMode: SupabaseContext['authMode']) {
return { authMode }
}
Expand Down
2 changes: 1 addition & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ function verifyCredentials(
): Promise<{ data: AuthResult; error: null } | { data: null; error: AuthError }>
```

Verifies pre-extracted credentials against allowed auth modes. Tries each mode in order — first match wins.
Verifies pre-extracted credentials against allowed auth modes. Tries each mode in order — first match wins. When combining `'user'` with key-based modes, list the key-based modes first (e.g. `['secret', 'user']`) — see the ordering note in [`auth-modes.md`](auth-modes.md).

### extractCredentials

Expand Down
21 changes: 18 additions & 3 deletions docs/auth-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ 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) => {
fetch: withSupabase({ auth: ['secret', 'user'] }, async (req, ctx) => {
// ctx.authMode tells you which mode matched
if (ctx.authMode === 'user') {
// Called by an authenticated user
Expand All @@ -153,6 +153,19 @@ A request with a valid JWT matches `'user'`. A request with a valid secret key m

**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.

> [!IMPORTANT]
> **List key-based modes before `'user'`.** When you combine `'user'` with `'publishable'`/`'secret'`, put the key-based modes first and keep `'user'` **last** — e.g. `['secret', 'user']`, not `['user', 'secret']`.
>
> This is because of how Supabase's API gateway behaves. A request that authenticates with an `apikey` but omits the `Authorization` header does **not** reach your function header-less: the gateway injects an `Authorization: Bearer <token>` derived from the key (an `anon` token for a publishable key, a `service_role` token for a secret key). Since that token _is_ present, a `'user'` mode listed first will try to verify it, fail, and reject the request with `InvalidCredentialsError` — **before** `'secret'`/`'publishable'` is ever reached.
>
> ```ts
> withSupabase({ auth: ['secret', 'user'] }, handler) // ✅ secret matches the apikey first
> withSupabase({ auth: ['publishable', 'secret', 'user'] }, handler) // ✅ user last
> withSupabase({ auth: ['user', 'secret'] }, handler) // ❌ user verifies the injected token → 401
> ```
>
> A genuine user request (a real JWT in `Authorization`) still matches `'user'` regardless of position, because the key-based modes fall through when the `apikey` isn't one of their keys.

## 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.
Expand Down Expand Up @@ -194,8 +207,10 @@ When using named keys, `ctx.authMode` tells you the mode and `keyName` on the `A
### Combining named keys with other modes

```ts
withSupabase({ auth: ['user', 'publishable:web'] }, async (_req, ctx) => {
// Accepts either a valid JWT or the "web" publishable key
withSupabase({ auth: ['publishable:web', 'user'] }, async (_req, ctx) => {
// Accepts either the "web" publishable key or a valid JWT.
// Key-based mode first, `'user'` last — see "List key-based modes before
// `'user'`" above.
return Response.json({ authMode: ctx.authMode })
})
```
Expand Down
4 changes: 2 additions & 2 deletions docs/core-primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,9 @@ console.log(auth!.userClaims) // { id: '...', email: '...', role: 'authenticated
Supports all auth mode syntax — single mode, arrays, and named keys:

```ts
// Multiple modes
// Multiple modes — key-based modes before 'user' (see auth-modes.md)
const { data: auth } = await verifyCredentials(creds, {
auth: ['user', 'publishable'],
auth: ['publishable', 'user'],
})

// Named key
Expand Down
4 changes: 3 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ JWT verification in `user` mode works as follows:

If JWKS is not configured (`SUPABASE_JWKS` is missing or malformed), `user` mode is unavailable and will always reject requests.

**No silent downgrade.** When `user` is combined with other modes (e.g. `auth: ['user', 'publishable']`), a JWT that is present but fails verification rejects the request with `InvalidCredentialsError` — it does not fall through to the next mode. This prevents a bad token paired with a valid `apikey` (or with `'none'`) from being silently downgraded to a less-privileged auth mode. Requests that simply omit the `Authorization` header still fall through as expected.
**No silent downgrade.** When `user` is combined with other modes (e.g. `auth: ['publishable', 'user']`), a JWT that is present but fails verification rejects the request with `InvalidCredentialsError` — it does not fall through to the next mode. This prevents a bad token paired with a valid `apikey` (or with `'none'`) from being silently downgraded to a less-privileged auth mode.

**List key-based modes before `user`.** At the SDK level, a request that omits the `Authorization` header falls through to the next mode — but behind Supabase's API gateway it won't. For an `apikey` request with no `Authorization` header, the gateway injects an `Authorization: Bearer <token>` derived from the key (an `anon` token for a publishable key, a `service_role` token for a secret key). A `user` mode placed before your key-based mode would then try to verify that injected token, fail, and reject before the key-based mode is reached. Always order key-based modes (`secret`/`publishable`) before `user` — e.g. `['secret', 'user']`. See [`auth-modes.md`](auth-modes.md).

## CORS handling

Expand Down
4 changes: 3 additions & 1 deletion skills/supabase-server/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Server-side utilities for Supabase. Handles auth, client creation, and context i

- Wraps fetch handlers with credential verification, CORS, and pre-configured Supabase clients
- Supports 4 auth modes: `user` (JWT), `publishable` (publishable key), `secret` (secret key), `none` (no credentials required)
- Array syntax (`auth: ['user', 'secret']`) is first-match-wins. A present-but-invalid JWT rejects with `InvalidCredentialsError` — it does not silently downgrade to the next mode.
- Array syntax (`auth: ['secret', 'user']`) is first-match-wins. A present-but-invalid JWT rejects with `InvalidCredentialsError` — it does not silently downgrade to the next mode. **List key-based modes (`secret`/`publishable`) before `user`:** behind Supabase's API gateway an `apikey` request that omits `Authorization` still arrives with a gateway-injected bearer token (`anon` for a publishable key, `service_role` for a secret key), so a `user` mode placed first would try to verify it and reject before the key-based mode is reached.
- Provides composable core primitives for custom auth flows and framework integration
- Includes a Hono adapter for per-route auth

Expand Down Expand Up @@ -207,6 +207,8 @@ Use `auth: 'secret'` to accept any secret key, or `auth: 'secret:name'` to requi

**On `auth: ['user', 'none']`.** A stale or malformed JWT on such an endpoint is rejected with `InvalidCredentialsError` — it is not silently downgraded to anonymous. Callers that might hold a cached/expired token should either omit the `Authorization` header entirely or refresh before calling. If the goal is "anonymous unless a valid user is signed in," this is the correct behavior; if the goal is truly "accept anything," use `auth: 'none'` on its own.

> **Gateway caveat:** behind Supabase's API gateway this pattern only works when the caller sends **no `apikey`** either. An `apikey` with no `Authorization` header arrives with a gateway-injected bearer token (`anon`/`service_role`) that `user` mode will try — and fail — to verify, rejecting with `InvalidCredentialsError` before `none` is reached. Since `none` matches everything it must stay last, so no reordering fixes this; the caller must omit the `apikey`. Note the Supabase JS client always sends the publishable key, so anonymous calls through it will hit this.

## Edge Function recipes

### Function-to-function calls
Expand Down
8 changes: 7 additions & 1 deletion src/core/verify-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,12 @@ async function tryMode(
* through to the next mode. Use {@link verifyAuth} to extract and verify in a
* single call.
*
* When combining `'user'` with key-based modes, list the key-based modes first
* (e.g. `['secret', 'user']`). Behind Supabase's API gateway an `apikey`
* request that omits the `Authorization` header still arrives with a
* gateway-injected bearer token, so a `'user'` mode placed first would attempt
* to verify it and reject before the key-based mode is reached.
*
* @param credentials - The credentials to verify (from {@link extractCredentials}).
* @param options - Allowed auth modes and optional env overrides.
* @returns `{ data: AuthResult, error: null }` on success, `{ data: null, error: AuthError }` on failure.
Expand All @@ -255,7 +261,7 @@ async function tryMode(
* ```ts
* const credentials = extractCredentials(request)
* const { data: auth, error } = await verifyCredentials(credentials, {
* auth: ['user', 'publishable'],
* auth: ['publishable', 'user'],
* })
* if (error) {
* return Response.json({ message: error.message }, { status: error.status })
Expand Down
17 changes: 13 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import type {
* // A mode is tried only when its credential is present; a JWT that is
* // present but fails verification rejects immediately rather than falling
* // through to the next mode.
* withSupabase({ auth: ['user', 'publishable'] }, handler)
* // List key-based modes before 'user' (see WithSupabaseConfig.auth).
* withSupabase({ auth: ['publishable', 'user'] }, handler)
* ```
*/
export type AuthMode = 'none' | 'publishable' | 'secret' | 'user'
Expand All @@ -45,8 +46,8 @@ export type Allow = AuthMode
* // Accept any secret key
* withSupabase({ auth: 'secret:*' }, handler)
*
* // Mix named keys with other modes
* withSupabase({ auth: ['user', 'publishable:web_app'] }, handler)
* // Mix named keys with other modes (key-based modes before 'user')
* withSupabase({ auth: ['publishable:web_app', 'user'] }, handler)
* ```
*/
export type AuthModeWithKey =
Expand Down Expand Up @@ -228,8 +229,9 @@ export interface UserClaims {
* const config: WithSupabaseConfig = { auth: 'user' }
*
* // Accept users or service-to-service calls, custom CORS headers
* // (key-based modes listed before 'user' — see the `auth` field docs)
* const config: WithSupabaseConfig = {
* auth: ['user', 'secret'],
* auth: ['secret', 'user'],
* cors: { 'Access-Control-Allow-Origin': 'https://myapp.com' },
* }
*
Expand All @@ -243,6 +245,13 @@ export interface WithSupabaseConfig {
* A mode falls through only when its credential is absent; a present-but-invalid
* JWT short-circuits the chain with `InvalidCredentialsError`.
*
* When combining `'user'` with `'publishable'`/`'secret'`, list the key-based
* modes first and keep `'user'` last (e.g. `['secret', 'user']`). Behind
* Supabase's API gateway, an `apikey` request that omits the `Authorization`
* header still arrives with a gateway-injected bearer token, so a `'user'`
* mode placed first would try to verify it and reject before the key-based
* mode is reached.
*
* @defaultValue `"user"`
*/
auth?: AuthModeWithKey | AuthModeWithKey[]
Expand Down
Loading