From 8a0a5d13958bc9896589270939e6fcc5287c09f8 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Fri, 11 Sep 2026 18:14:39 +0300 Subject: [PATCH] fix: name the key a bare secret/publishable mode rejected --- docs/api-reference.md | 1 + docs/auth-modes.md | 13 ++++ docs/error-handling.md | 2 + src/core/verify-credentials.test.ts | 116 ++++++++++++++++++++++++++++ src/core/verify-credentials.ts | 64 ++++++++++++--- src/errors.ts | 20 +++++ 6 files changed, 207 insertions(+), 9 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 7487139..25457ff 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -854,5 +854,6 @@ interface AuthFailureContext { apikey: 'absent' | 'publishable' | 'secret' | 'legacy-jwt' | 'unrecognized' } configuredKeyNames?: Record + matchedKey?: { kind: 'publishable' | 'secret'; name: string; mode: string } } ``` diff --git a/docs/auth-modes.md b/docs/auth-modes.md index 03eb16a..4b058e0 100644 --- a/docs/auth-modes.md +++ b/docs/auth-modes.md @@ -200,6 +200,19 @@ withSupabase({ auth: 'publishable:*' }, handler) withSupabase({ auth: 'secret:*' }, handler) ``` +### Callers on Vercel + +The Supabase Vercel integration sets `SUPABASE_SECRET_KEY` in Vercel to the most recently created secret key of the project. That value changes whenever a secret key is added or rotated, so it does not stay under any one name. Bare `secret` rejects it as soon as a key newer than `default` exists. For a function called from Vercel-hosted code, either accept any secret key or give Vercel a key of its own: + +```ts +// Accept whichever secret key the integration synced +withSupabase({ auth: 'secret:*' }, handler) + +// Or create a secret key named "vercel", set it in Vercel yourself as a +// variable the integration does not manage, and accept only that key +withSupabase({ auth: 'secret:vercel' }, 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. diff --git a/docs/error-handling.md b/docs/error-handling.md index f5523f3..ea6b778 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -135,6 +135,8 @@ The `hint` prioritises format mismatches, since sending the wrong _kind_ of key - a legacy JWT-style `anon` / `service_role` key, where an `sb_publishable_…` / `sb_secret_…` key is expected - a value that isn't a Supabase API key at all +When the key is a configured key of the same kind, held under a name the attempted mode does not accept, the `hint` names that key and the modes that would accept it: `secret:` for that key alone, or `secret:*` for any key in the set. `details.matchedKeyName` carries the name. Bare `secret` and `publishable` accept only the key named `default`, so a named key sent to a bare mode lands here. + Otherwise the key was well-formed but simply unknown — usually a different Supabase project. `details.configuredKeyNames` lists the names configured for the attempted modes, and `details.received.apikey` gives the format of what you sent. ### `INVALID_JWT` diff --git a/src/core/verify-credentials.test.ts b/src/core/verify-credentials.test.ts index 1c9f8dc..98aeff3 100644 --- a/src/core/verify-credentials.test.ts +++ b/src/core/verify-credentials.test.ts @@ -1113,6 +1113,122 @@ describe('verifyCredentials', () => { expect(result.data!.authMode).toBe('secret') }) + describe('key matched under another name', () => { + it('names the key bare secret rejected and the modes that would accept it', async () => { + const error = await failWith( + { token: null, apikey: 'sb_secret_vercel' }, + { + auth: 'secret', + env: makeEnv({ + secretKeys: { + default: 'sb_secret_default', + vercel: 'sb_secret_vercel', + }, + }), + }, + ) + expect(error.code).toBe(InvalidApiKeyError) + expect(error.status).toBe(401) + expect(error.hint).toContain('secret key named "vercel"') + expect(error.hint).toContain('accepts only the key named "default"') + expect(error.hint).toContain("'secret:vercel'") + expect(error.hint).toContain("'secret:*'") + expect(error.details).toMatchObject({ matchedKeyName: 'vercel' }) + }) + + it('names the key a named secret mode rejected', async () => { + const error = await failWith( + { token: null, apikey: 'sb_secret_mobile' }, + { + auth: 'secret:web', + env: makeEnv({ + secretKeys: { web: 'sb_secret_web', mobile: 'sb_secret_mobile' }, + }), + }, + ) + expect(error.code).toBe(InvalidApiKeyError) + expect(error.hint).toContain('secret key named "mobile"') + expect(error.hint).toContain('accepts only the key named "web"') + expect(error.hint).toContain("'secret:mobile'") + expect(error.details).toMatchObject({ matchedKeyName: 'mobile' }) + }) + + it('names the key bare publishable rejected', async () => { + const error = await failWith( + { token: null, apikey: 'sb_publishable_web' }, + { + auth: 'publishable', + env: makeEnv({ + publishableKeys: { + default: 'sb_publishable_default', + web: 'sb_publishable_web', + }, + }), + }, + ) + expect(error.code).toBe(InvalidApiKeyError) + expect(error.hint).toContain('publishable key named "web"') + expect(error.hint).toContain("'publishable:web'") + expect(error.hint).toContain("'publishable:*'") + expect(error.details).toMatchObject({ matchedKeyName: 'web' }) + }) + + it('still names the key when the key mode sits inside a chain', async () => { + const error = await failWith( + { token: null, apikey: 'sb_secret_vercel' }, + { + auth: ['user', 'secret'], + env: makeEnv({ + secretKeys: { + default: 'sb_secret_default', + vercel: 'sb_secret_vercel', + }, + }), + }, + ) + expect(error.code).toBe(InvalidApiKeyError) + expect(error.hint).toContain('secret key named "vercel"') + expect(error.details).toMatchObject({ matchedKeyName: 'vercel' }) + }) + + it('never puts key values in the hint or details', async () => { + const error = await failWith( + { token: null, apikey: 'sb_secret_vercel' }, + { + auth: 'secret', + env: makeEnv({ + secretKeys: { + default: 'sb_secret_default', + vercel: 'sb_secret_vercel', + }, + }), + }, + ) + const serialized = JSON.stringify(error.toJSON()) + expect(serialized).toContain('vercel') + expect(serialized).not.toContain('sb_secret_vercel') + expect(serialized).not.toContain('sb_secret_default') + }) + + it('keeps the generic hint when the key matches nothing', async () => { + const error = await failWith( + { token: null, apikey: 'sb_secret_nope' }, + { + auth: 'secret', + env: makeEnv({ + secretKeys: { + default: 'sb_secret_default', + vercel: 'sb_secret_vercel', + }, + }), + }, + ) + expect(error.code).toBe(InvalidApiKeyError) + expect(error.hint).toContain('matched no configured key') + expect(error.details).not.toHaveProperty('matchedKeyName') + }) + }) + it('stamps provenance on every error', async () => { const error = await failWith( { token: null, apikey: null }, diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index c888c75..3b517ab 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -108,8 +108,26 @@ type ModeSkip = mode: string keyKind: 'publishable' | 'secret' } - /** A key was present and the mode had keys, but none matched. */ - | { reason: 'apikey-mismatch' } + /** + * A key was present and the mode had keys, but none matched. When the key + * is a configured key of the same kind under another name, `matchedKeyName` + * carries that name so the error can point at the mode that would accept it. + */ + | { + reason: 'apikey-mismatch' + mode: string + keyKind: 'publishable' | 'secret' + matchedKeyName: string | null + } + +/** + * An `apikey-mismatch` skip whose key is a configured key under another name. + * + * @internal + */ +type MisnamedKeySkip = Extract & { + matchedKeyName: string +} /** * Result of attempting a single auth mode. @@ -128,11 +146,6 @@ type ModeOutcome = const NoToken: ModeOutcome = { kind: 'skip', skip: { reason: 'no-token' } } const NoApiKey: ModeOutcome = { kind: 'skip', skip: { reason: 'no-apikey' } } -const ApiKeyMismatch: ModeOutcome = { - kind: 'skip', - skip: { reason: 'apikey-mismatch' }, -} - /** * Matches an `apikey` against a mode's key set, honouring the `:*` wildcard and * named-key syntax. Returns the matched key name, or `null` when nothing matched. @@ -207,7 +220,25 @@ async function tryMode( } const matched = await matchApiKey(credentials.apikey, keys, keyName) - if (matched === null) return ApiKeyMismatch + if (matched === null) { + // A wildcard has already compared against every key, so nothing else + // can match. Any other mode may have rejected a valid key of this kind + // held under a name it does not accept. That name is safe to report: + // the caller already holds the key. + const matchedKeyName = + keyName === '*' + ? null + : await matchApiKey(credentials.apikey, keys, '*') + return { + kind: 'skip', + skip: { + reason: 'apikey-mismatch', + mode, + keyKind: base, + matchedKeyName, + }, + } + } return { kind: 'match', @@ -389,7 +420,22 @@ function explainFallthrough( }) } if (apikey !== 'absent') { - return Errors[InvalidApiKeyError](context) + const misnamed = skips.find( + (skip): skip is MisnamedKeySkip => + skip.reason === 'apikey-mismatch' && skip.matchedKeyName !== null, + ) + return Errors[InvalidApiKeyError]( + misnamed + ? { + ...context, + matchedKey: { + kind: misnamed.keyKind, + name: misnamed.matchedKeyName, + mode: misnamed.mode, + }, + } + : context, + ) } return Errors[InvalidCredentialsError](context) } diff --git a/src/errors.ts b/src/errors.ts index 16092dd..a363813 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -611,6 +611,13 @@ export interface AuthFailureContext { * Omitted for modes that don't use API keys. */ configuredKeyNames?: Record + + /** + * Set when the rejected `apikey` is a configured key of `kind` held under + * `name`, and `mode` is the attempted mode that accepts a different name. + * Carries the name only, never the value. + */ + matchedKey?: { kind: 'publishable' | 'secret'; name: string; mode: string } } /** @@ -664,6 +671,16 @@ const ApiKeyFormatLabel: Record = { */ function apiKeyHint(context: AuthFailureContext): string { const { authModes, received } = context + + if (context.matchedKey) { + const { kind, name, mode } = context.matchedKey + const colonIndex = mode.indexOf(':') + const accepted = colonIndex === -1 ? 'default' : mode.slice(colonIndex + 1) + return ( + `The key is the ${kind} key named "${name}", but auth: '${mode}' accepts only the key named "${accepted}". ` + + `Use auth: '${kind}:${name}' to accept that key, or auth: '${kind}:*' to accept any ${kind} key.` + ) + } const keyModes = authModes.filter((mode) => credentialForMode(mode)?.startsWith('apikey'), ) @@ -781,6 +798,9 @@ const AuthErrorMap = { ...(context.configuredKeyNames ? { configuredKeyNames: context.configuredKeyNames } : {}), + ...(context.matchedKey + ? { matchedKeyName: context.matchedKey.name } + : {}), }, }, ),