From 94a25c9699de9795a6aed5ef044f1d0776d0ce14 Mon Sep 17 00:00:00 2001 From: Tomas Pozo Date: Sat, 6 Jun 2026 22:02:15 -0500 Subject: [PATCH 1/2] docs(auth): order key-based modes before 'user' in all examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behind Supabase's API gateway, an apikey request that omits the Authorization header still reaches the function with a gateway-injected 'Bearer ' (anon for a publishable key, service_role for a secret key). A 'user' mode placed before the key-based mode then tries to verify that injected token, fails, and rejects with InvalidCredentialsError before the apikey mode is reached. Document the rule — list 'secret'/'publishable' before 'user', keep 'user' last — across auth-modes, security, api-reference, core-primitives, the adapter guides, the README, and the withSupabase/verifyCredentials JSDoc, and flip every combined-auth example to the safe ordering. Corrects the security.md claim that an absent Authorization header always falls through (true at the SDK level, not behind the gateway). --- README.md | 5 +++-- docs/adapters/h3.md | 2 +- docs/adapters/hono.md | 2 +- docs/adapters/nestjs.md | 2 +- docs/api-reference.md | 2 +- docs/auth-modes.md | 21 ++++++++++++++++++--- docs/core-primitives.md | 4 ++-- docs/security.md | 4 +++- src/core/verify-credentials.ts | 8 +++++++- src/types.ts | 17 +++++++++++++---- 10 files changed, 50 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 5a7b8ca..c5fcaa1 100644 --- a/README.md +++ b/README.md @@ -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) { @@ -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`. diff --git a/docs/adapters/h3.md b/docs/adapters/h3.md index c45563c..5b7c073 100644 --- a/docs/adapters/h3.md +++ b/docs/adapters/h3.md @@ -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 } diff --git a/docs/adapters/hono.md b/docs/adapters/hono.md index 78d5119..b54cfde 100644 --- a/docs/adapters/hono.md +++ b/docs/adapters/hono.md @@ -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 }) }) diff --git a/docs/adapters/nestjs.md b/docs/adapters/nestjs.md index 0229432..4081cb1 100644 --- a/docs/adapters/nestjs.md +++ b/docs/adapters/nestjs.md @@ -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 } } diff --git a/docs/api-reference.md b/docs/api-reference.md index 8644251..6729a8c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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 diff --git a/docs/auth-modes.md b/docs/auth-modes.md index 3c9c834..0301c00 100644 --- a/docs/auth-modes.md +++ b/docs/auth-modes.md @@ -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 @@ -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 ` 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. @@ -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 }) }) ``` diff --git a/docs/core-primitives.md b/docs/core-primitives.md index 5a280cd..217cef9 100644 --- a/docs/core-primitives.md +++ b/docs/core-primitives.md @@ -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 diff --git a/docs/security.md b/docs/security.md index bd39e41..c97640b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 ` 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 diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index 13344cb..faabfd3 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -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. @@ -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 }) diff --git a/src/types.ts b/src/types.ts index 438e11e..cb42799 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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' @@ -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 = @@ -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' }, * } * @@ -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[] From 14ae41d4cd8dc33cfeaad15cb768d35040e9d50e Mon Sep 17 00:00:00 2001 From: Tomas Pozo Date: Sat, 6 Jun 2026 22:23:10 -0500 Subject: [PATCH 2/2] docs(auth): apply key-based-modes-first ordering to the skill --- skills/supabase-server/SKILL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/skills/supabase-server/SKILL.md b/skills/supabase-server/SKILL.md index eb5c2b8..e1d24ed 100644 --- a/skills/supabase-server/SKILL.md +++ b/skills/supabase-server/SKILL.md @@ -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 @@ -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