From a56ff4f3aea779a3adba468203404fbf6ed032ad Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Tue, 21 Apr 2026 19:55:42 +0200 Subject: [PATCH 1/2] 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. --- src/core/verify-credentials.test.ts | 102 ++++++++++++++++++++++++++++ src/core/verify-credentials.ts | 18 +++-- 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/src/core/verify-credentials.test.ts b/src/core/verify-credentials.test.ts index cd93be6..9b71675 100644 --- a/src/core/verify-credentials.test.ts +++ b/src/core/verify-credentials.test.ts @@ -416,4 +416,106 @@ describe('verifyCredentials', () => { expect(result.data!.authType).toBe('always') }) }) + + describe('invalid credential rejection (no silent fallthrough)', () => { + let jwks: JsonWebKeySet + + beforeAll(async () => { + const { publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + jwks = { keys: [publicJwk] } + }) + + it('rejects invalid JWT instead of falling through to always mode', async () => { + const creds: Credentials = { token: 'garbage.jwt.token', apikey: null } + const result = await verifyCredentials(creds, { + allow: ['user', 'always'], + env: makeEnv({ jwks }), + }) + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidCredentialsError) + }) + + it('rejects expired JWT instead of falling through to always mode', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + const expiredJwks = { keys: [publicJwk] } + + const expiredToken = await new SignJWT({ sub: 'user-123' }) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuedAt(Math.floor(Date.now() / 1000) - 7200) + .setExpirationTime(Math.floor(Date.now() / 1000) - 3600) + .sign(privateKey) + + const creds: Credentials = { token: expiredToken, apikey: null } + const result = await verifyCredentials(creds, { + allow: ['user', 'always'], + env: makeEnv({ jwks: expiredJwks }), + }) + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidCredentialsError) + }) + + it('falls through to always when no token is present', async () => { + const creds: Credentials = { token: null, apikey: null } + const result = await verifyCredentials(creds, { + allow: ['user', 'always'], + env: makeEnv({ jwks }), + }) + expect(result.error).toBeNull() + expect(result.data!.authType).toBe('always') + }) + + it('rejects invalid JWT even when public mode follows', async () => { + const creds: Credentials = { + token: 'garbage.jwt.token', + apikey: 'sb_publishable_xyz', + } + const result = await verifyCredentials(creds, { + allow: ['user', 'public'], + env: makeEnv({ jwks }), + }) + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidCredentialsError) + }) + + it('rejects invalid JWT instead of falling through to secret mode', async () => { + const creds: Credentials = { + token: 'garbage.jwt.token', + apikey: 'sb_secret_xyz', + } + const result = await verifyCredentials(creds, { + allow: ['user', 'secret'], + env: makeEnv({ jwks }), + }) + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidCredentialsError) + }) + + it('rejects JWT with missing sub claim', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + const noSubJwks = { keys: [publicJwk] } + + const noSubToken = await new SignJWT({ role: 'authenticated' }) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + const creds: Credentials = { token: noSubToken, apikey: null } + const result = await verifyCredentials(creds, { + allow: 'user', + env: makeEnv({ jwks: noSubJwks }), + }) + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidCredentialsError) + }) + }) }) diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index bce9254..f12daa6 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -73,16 +73,23 @@ function claimsToUserClaims(claims: JWTClaims): UserClaims { } } +const INVALID = Symbol('invalid') + /** * Attempts to authenticate credentials against a single auth mode. - * Returns the {@link AuthResult} on success, or `null` if the mode doesn't match. + * + * Returns: + * - `AuthResult` on success. + * - `null` if the mode doesn't apply (no relevant credential present — safe to try the next mode). + * - `INVALID` if a credential was present but failed verification (must reject immediately). + * * @internal */ async function tryMode( mode: AllowWithKey, credentials: Credentials, env: SupabaseEnv, -): Promise { +): Promise { const { base, keyName } = parseAllowMode(mode) switch (base) { @@ -166,7 +173,7 @@ async function tryMode( const jwkSet = createLocalJWKSet(env.jwks) const { payload } = await jwtVerify(credentials.token, jwkSet) if (typeof payload.sub !== 'string') { - return null + return INVALID } const claims = payload as unknown as JWTClaims return { @@ -177,7 +184,7 @@ async function tryMode( keyName: null, } } catch { - return null + return INVALID } } @@ -225,6 +232,9 @@ export async function verifyCredentials( for (const mode of modes) { const result = await tryMode(mode, credentials, env) + if (result === INVALID) { + return { data: null, error: Errors[InvalidCredentialsError]() } + } if (result) { return { data: result, error: null } } From 10f15603dcfa6214cfb98c4f108554a4d53140ff Mon Sep 17 00:00:00 2001 From: Tomas Pozo Date: Wed, 22 Apr 2026 19:15:51 -0500 Subject: [PATCH 2/2] docs: clarify invalid-JWT no-fallthrough semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 2 +- docs/api-reference.md | 22 +++++++++++----------- docs/auth-modes.md | 4 +++- docs/error-handling.md | 10 +++++----- docs/security.md | 2 ++ skills/supabase-server/SKILL.md | 3 +++ src/core/verify-credentials.ts | 7 +++++-- src/types.ts | 7 ++++++- 8 files changed, 36 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 8a384d9..d63e1ec 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ await fetch(refreshEndpoint, { | `"secret"` | Valid secret key | Server-to-server, internal calls | | `"always"` | None | Open endpoints, wrappers that handle their own auth | -Array syntax (`allow: ["user", "secret"]`) accepts multiple auth methods — first match wins. +Array syntax (`allow: ["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). Named key validation: `allow: "public:web_app"` or `allow: "secret:automations"` validates against a specific named key in `SUPABASE_PUBLISHABLE_KEYS` or `SUPABASE_SECRET_KEYS`. diff --git a/docs/api-reference.md b/docs/api-reference.md index d5c636b..b360e03 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -291,17 +291,17 @@ class AuthError extends Error { ## Error Code Constants -| Constant | Value | Class | Meaning | -| ----------------------------------- | ----------------------------------- | ----------- | --------------------------------- | -| `EnvGenericError` | `'ENV_ERROR'` | `EnvError` | Generic environment error | -| `MissingSupabaseURLError` | `'MISSING_SUPABASE_URL'` | `EnvError` | `SUPABASE_URL` not set | -| `MissingPublishableKeyError` | `'MISSING_PUBLISHABLE_KEY'` | `EnvError` | Named publishable key not found | -| `MissingDefaultPublishableKeyError` | `'MISSING_DEFAULT_PUBLISHABLE_KEY'` | `EnvError` | No default publishable key | -| `MissingSecretKeyError` | `'MISSING_SECRET_KEY'` | `EnvError` | Named secret key not found | -| `MissingDefaultSecretKeyError` | `'MISSING_DEFAULT_SECRET_KEY'` | `EnvError` | No default secret key | -| `AuthGenericError` | `'AUTH_ERROR'` | `AuthError` | Generic auth error | -| `InvalidCredentialsError` | `'INVALID_CREDENTIALS'` | `AuthError` | No credential matched | -| `CreateSupabaseClientError` | `'CREATE_SUPABASE_CLIENT_ERROR'` | `AuthError` | Client creation failed after auth | +| Constant | Value | Class | Meaning | +| ----------------------------------- | ----------------------------------- | ----------- | ------------------------------------------------- | +| `EnvGenericError` | `'ENV_ERROR'` | `EnvError` | Generic environment error | +| `MissingSupabaseURLError` | `'MISSING_SUPABASE_URL'` | `EnvError` | `SUPABASE_URL` not set | +| `MissingPublishableKeyError` | `'MISSING_PUBLISHABLE_KEY'` | `EnvError` | Named publishable key not found | +| `MissingDefaultPublishableKeyError` | `'MISSING_DEFAULT_PUBLISHABLE_KEY'` | `EnvError` | No default publishable key | +| `MissingSecretKeyError` | `'MISSING_SECRET_KEY'` | `EnvError` | Named secret key not found | +| `MissingDefaultSecretKeyError` | `'MISSING_DEFAULT_SECRET_KEY'` | `EnvError` | No default secret key | +| `AuthGenericError` | `'AUTH_ERROR'` | `AuthError` | Generic auth error | +| `InvalidCredentialsError` | `'INVALID_CREDENTIALS'` | `AuthError` | No credential matched, or JWT failed verification | +| `CreateSupabaseClientError` | `'CREATE_SUPABASE_CLIENT_ERROR'` | `AuthError` | Client creation failed after auth | --- diff --git a/docs/auth-modes.md b/docs/auth-modes.md index c6cd4af..36e2331 100644 --- a/docs/auth-modes.md +++ b/docs/auth-modes.md @@ -147,6 +147,8 @@ export default { 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. @@ -198,6 +200,6 @@ withSupabase({ allow: ['user', 'public:web'] }, async (_req, ctx) => { 1. `extractCredentials(request)` reads `Authorization: Bearer ` 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` +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 diff --git a/docs/error-handling.md b/docs/error-handling.md index 85d7bae..20d495f 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -21,11 +21,11 @@ Thrown when a required environment variable is missing or malformed. Always `sta Thrown when authentication or authorization fails. Status is `401` for invalid credentials, `500` for server-side auth failures. -| Code | Status | Meaning | -| ------------------------------ | ------ | ------------------------------------------- | -| `INVALID_CREDENTIALS` | 401 | No credential matched any allowed auth mode | -| `CREATE_SUPABASE_CLIENT_ERROR` | 500 | Auth succeeded but client creation failed | -| `AUTH_ERROR` | 401 | Generic authentication error | +| Code | Status | Meaning | +| ------------------------------ | ------ | ----------------------------------------------------------------------------------------- | +| `INVALID_CREDENTIALS` | 401 | No credential matched any allowed auth mode, or a JWT was present but failed verification | +| `CREATE_SUPABASE_CLIENT_ERROR` | 500 | Auth succeeded but client creation failed | +| `AUTH_ERROR` | 401 | Generic authentication error | ## How errors surface in each layer diff --git a/docs/security.md b/docs/security.md index 5aba948..6d5a02b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -60,6 +60,8 @@ 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. `allow: ['user', 'public']`), 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 `'always'`) from being silently downgraded to a less-privileged auth mode. Requests that simply omit the `Authorization` header still fall through as expected. + ## CORS handling `withSupabase` handles CORS automatically: diff --git a/skills/supabase-server/SKILL.md b/skills/supabase-server/SKILL.md index 3919ae8..a8c1717 100644 --- a/skills/supabase-server/SKILL.md +++ b/skills/supabase-server/SKILL.md @@ -24,6 +24,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), `public` (publishable key), `secret` (secret key), `always` (none) +- Array syntax (`allow: ['user', 'secret']`) is first-match-wins. A present-but-invalid JWT rejects with `InvalidCredentialsError` — it does not silently downgrade to the next mode. - Provides composable core primitives for custom auth flows and framework integration - Includes a Hono adapter for per-route auth @@ -199,6 +200,8 @@ Use `allow: 'secret'` to accept any secret key, or `allow: 'secret:name'` to req **Never use `allow: 'always'` for endpoints that read or write user data without verifying who the caller is.** +**On `allow: ['user', 'always']`.** 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 `allow: 'always'` on its own. + ## Edge Function recipes ### Function-to-function calls diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index f12daa6..c82ff14 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -196,8 +196,11 @@ async function tryMode( /** * Verifies pre-extracted credentials against one or more allowed auth modes. * - * Tries each mode in order — first match wins. Use {@link verifyAuth} to extract - * and verify in a single call. + * Tries each mode in order — first match wins. A mode is only tried when its + * credential is present; a JWT that is present but fails verification + * short-circuits the chain with `InvalidCredentialsError` instead of falling + * through to the next mode. Use {@link verifyAuth} to extract and verify in a + * single call. * * @param credentials - The credentials to verify (from {@link extractCredentials}). * @param options - Allowed auth modes and optional env overrides. diff --git a/src/types.ts b/src/types.ts index fa62617..7393d9e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,7 +16,10 @@ import type { * // Single mode * withSupabase({ allow: 'user' }, handler) * - * // Multiple modes — the first match wins + * // Multiple modes — the first match wins. + * // 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({ allow: ['user', 'public'] }, handler) * ``` */ @@ -213,6 +216,8 @@ export interface UserClaims { export interface WithSupabaseConfig { /** * Auth mode(s) to accept. Modes are tried in order — the first match wins. + * A mode falls through only when its credential is absent; a present-but-invalid + * JWT short-circuits the chain with `InvalidCredentialsError`. * * @defaultValue `"user"` */