From 383648ee0add0c0ae2f197a936d2d5e96bece027 Mon Sep 17 00:00:00 2001 From: Mandar Joshi Date: Tue, 15 Sep 2026 21:22:13 +0530 Subject: [PATCH 1/3] feat: validate JWT audience and issuer claims --- docs/security.md | 8 +- src/core/parts/gate.ts | 2 + src/core/verify-auth.ts | 4 + src/core/verify-credentials.test.ts | 109 ++++++++++++++++++- src/core/verify-credentials.ts | 12 +- src/core/verify-user-jwt.ts | 17 ++- src/create-supabase-context.ts | 2 + src/middleware/claims/index.test.ts | 38 +++++++ src/middleware/claims/index.ts | 9 +- src/middleware/required-claims/index.test.ts | 38 +++++++ src/middleware/required-claims/index.ts | 9 +- src/types.ts | 4 + src/with-supabase.test.ts | 56 ++++++++++ 13 files changed, 295 insertions(+), 13 deletions(-) diff --git a/docs/security.md b/docs/security.md index da7110a..b8647c9 100644 --- a/docs/security.md +++ b/docs/security.md @@ -60,11 +60,15 @@ JWT verification in `user` mode works as follows: 1. The `Authorization: Bearer ` header is extracted from the request 2. The token is verified against the JWKS from the `SUPABASE_JWKS` environment variable 3. Verification uses `jose`'s `jwtVerify` with a **local** key set — there are no network calls to a JWKS endpoint -4. The token must contain a `sub` (subject) claim to be considered valid -5. On success, the decoded claims are available as `ctx.userClaims` and `ctx.jwtClaims` +4. If `audience` is configured, the token's `aud` claim must match +5. If `issuer` is configured, the token's `iss` claim must match +6. The token must contain a `sub` (subject) claim to be considered valid +7. On success, the decoded claims are available as `ctx.userClaims` and `ctx.jwtClaims` If JWKS is not configured (`SUPABASE_JWKS` is missing or malformed), `user` mode is unavailable and will always reject requests. +**Audience and issuer validation.** In setups where multiple services share the same signing keys, a JWT minted by one service could be accepted by another. Passing `audience` and `issuer` options (e.g. `withSupabase({ auth: 'user', issuer: fromSupabaseUrl(SUPABASE_URL) })` or `withRequiredClaims({ issuer: fromSupabaseUrl(SUPABASE_URL) })`) prevents this by rejecting tokens that weren't issued for your specific service. Both are optional for backward compatibility but recommended in multi-service deployments. + **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. ## CORS handling diff --git a/src/core/parts/gate.ts b/src/core/parts/gate.ts index 7d2e115..ef9153f 100644 --- a/src/core/parts/gate.ts +++ b/src/core/parts/gate.ts @@ -37,6 +37,8 @@ export const withAuthGate: Middleware< const { data, error } = await verifyAuth(req, { auth: config.auth, allow: config.allow, + audience: config.audience, + issuer: config.issuer, env: config.env, }) if (error) return errorResponse(error, { errors: config.errors }) diff --git a/src/core/verify-auth.ts b/src/core/verify-auth.ts index 5599c12..be2cfec 100644 --- a/src/core/verify-auth.ts +++ b/src/core/verify-auth.ts @@ -29,6 +29,10 @@ export interface VerifyAuthOptions { * provided, `auth` wins. */ allow?: AuthModeWithKey | AuthModeWithKey[] + /** Expected JWT audience (`aud`) claim to validate. Applies to `user` mode only. */ + audience?: string | string[] + /** Expected JWT issuer (`iss`) claim to validate. Applies to `user` mode only. */ + issuer?: string | string[] /** Optional environment overrides (passed through to {@link resolveEnv}). */ env?: Partial diff --git a/src/core/verify-credentials.test.ts b/src/core/verify-credentials.test.ts index 3b4081b..599a6c1 100644 --- a/src/core/verify-credentials.test.ts +++ b/src/core/verify-credentials.test.ts @@ -289,17 +289,18 @@ describe('verifyCredentials', () => { describe('user mode', () => { let jwks: JSONWebKeySet let validTokens: string[] + let privateKey: CryptoKey + let jwtSecret: CryptoKey | Uint8Array beforeAll(async () => { - // Asymmetric JWK - const { privateKey, publicKey } = await generateKeyPair('RS256') - const publicJwk = await exportJWK(publicKey) + const keyPair = await generateKeyPair('RS256') + privateKey = keyPair.privateKey + const publicJwk = await exportJWK(keyPair.publicKey) publicJwk.alg = 'RS256' publicJwk.use = 'sig' publicJwk.kid = 'asymmetric-key-id' - // Symmetric Shared Secret JWK - const jwtSecret = await generateSecret('HS256', { + jwtSecret = await generateSecret('HS256', { extractable: true, }) const symmetricJwk = await exportJWK(jwtSecret) @@ -347,6 +348,104 @@ describe('verifyCredentials', () => { } }) + it('succeeds when JWT audience and issuer match configured values', async () => { + const token = await new SignJWT({ sub: 'user-123' }) + .setProtectedHeader({ alg: 'RS256', kid: 'asymmetric-key-id' }) + .setAudience('https://test.supabase.co') + .setIssuer('https://test.supabase.co/auth/v1') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + const result = await verifyCredentials( + { token, apikey: null }, + { + auth: 'user', + audience: 'https://test.supabase.co', + issuer: 'https://test.supabase.co/auth/v1', + env: makeEnv({ jwks }), + }, + ) + + expect(result.error).toBeNull() + expect(result.data!.jwtClaims!.aud).toBe('https://test.supabase.co') + expect(result.data!.jwtClaims!.iss).toBe( + 'https://test.supabase.co/auth/v1', + ) + }) + + it.each([ + [ + 'audience', + 'https://wrong.supabase.co', + 'https://test.supabase.co/auth/v1', + ], + [ + 'issuer', + 'https://test.supabase.co', + 'https://wrong.supabase.co/auth/v1', + ], + ])( + 'fails when configured JWT %s does not match', + async (_label, audience, issuer) => { + const token = await new SignJWT({ sub: 'user-123' }) + .setProtectedHeader({ alg: 'RS256', kid: 'asymmetric-key-id' }) + .setAudience('https://test.supabase.co') + .setIssuer('https://test.supabase.co/auth/v1') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + const result = await verifyCredentials( + { token, apikey: null }, + { + auth: 'user', + audience, + issuer, + env: makeEnv({ jwks }), + }, + ) + + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidJwtError) + }, + ) + + it('supports audience and issuer validation with symmetric HS256 keys', async () => { + const token = await new SignJWT({ sub: 'user-123' }) + .setProtectedHeader({ + alg: 'HS256', + kid: 'symmetric-shared-secret-key-id', + }) + .setAudience('https://test.supabase.co') + .setIssuer('https://test.supabase.co/auth/v1') + .setIssuedAt() + .setExpirationTime('1h') + .sign(jwtSecret) + + const matchResult = await verifyCredentials( + { token, apikey: null }, + { + auth: 'user', + audience: 'https://test.supabase.co', + issuer: 'https://test.supabase.co/auth/v1', + env: makeEnv({ jwks }), + }, + ) + expect(matchResult.error).toBeNull() + + const mismatchResult = await verifyCredentials( + { token, apikey: null }, + { + auth: 'user', + audience: 'https://other.supabase.co', + env: makeEnv({ jwks }), + }, + ) + expect(mismatchResult.error).not.toBeNull() + expect(mismatchResult.error!.code).toBe(InvalidJwtError) + }) + it('fails with invalid JWT', async () => { const creds: Credentials = { token: 'invalid.jwt.token', apikey: null } const result = await verifyCredentials(creds, { diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index 3b517ab..7794d47 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -50,6 +50,10 @@ export interface VerifyCredentialsOptions { * both are provided, `auth` wins. */ allow?: AuthModeWithKey | AuthModeWithKey[] + /** Expected JWT audience (`aud`) claim to validate. Applies to `user` mode only. */ + audience?: string | string[] + /** Expected JWT issuer (`iss`) claim to validate. Applies to `user` mode only. */ + issuer?: string | string[] /** Optional environment overrides (passed through to {@link resolveEnv}). */ env?: Partial @@ -183,6 +187,7 @@ async function tryMode( mode: AuthModeWithKey, credentials: Credentials, env: SupabaseEnv, + options?: VerifyCredentialsOptions, ): Promise { const { base, keyName } = parseAuthMode(mode) @@ -265,7 +270,10 @@ async function tryMode( return { kind: 'skip', skip: { reason: 'jwks-not-configured' } } } - const verified = await verifyUserJwt(credentials.token, env.jwks) + const verified = await verifyUserJwt(credentials.token, env.jwks, { + audience: options?.audience, + issuer: options?.issuer, + }) if (!verified.ok) { const { failure } = verified return { @@ -507,7 +515,7 @@ export async function verifyCredentials( const skips: ModeSkip[] = [] for (const mode of modes) { - const outcome = await tryMode(mode, credentials, env) + const outcome = await tryMode(mode, credentials, env, options) if (outcome.kind === 'match') { return { data: outcome.auth, error: null } } diff --git a/src/core/verify-user-jwt.ts b/src/core/verify-user-jwt.ts index 00e6767..011fabe 100644 --- a/src/core/verify-user-jwt.ts +++ b/src/core/verify-user-jwt.ts @@ -7,6 +7,7 @@ import { jwtVerify, type JWTPayload, type JWTVerifyGetKey, + type JWTVerifyOptions, } from 'jose' import type { JWTClaims, UserClaims } from '../types.js' @@ -183,6 +184,12 @@ const MalformedTokenHint = 'The Authorization header must carry a compact JWS — three base64url segments separated by dots. ' + 'Check the token was not truncated, URL-encoded, or wrapped in quotes.' +/** @internal */ +export interface VerifyUserJwtOptions { + audience?: string | string[] + issuer?: string | string[] +} + /** * Verifies a user JWT against the project JWKS — the single verification core * shared by `verifyCredentials`'s `user` mode and the `withClaims` / @@ -205,6 +212,7 @@ const MalformedTokenHint = export async function verifyUserJwt( token: string, jwks: JSONWebKeySet | URL, + options?: VerifyUserJwtOptions, ): Promise { let alg: string | undefined let kid: string | undefined @@ -248,6 +256,11 @@ export async function verifyUserJwt( const jwkResolver = getJwksResolver(jwks) let payload: JWTPayload | null = null + const verifyOptions: JWTVerifyOptions = { + audience: options?.audience, + issuer: options?.issuer, + } + // Symmetric algorithm requires importing the shared secret if (alg === 'HS256') { // A remote resolver fetches only from inside `jwtVerify`; its `jwks()` @@ -277,10 +290,10 @@ export async function verifyUserJwt( } const sharedSecret = await importJWK(jwk, 'HS256') - const verify = await jwtVerify(token, sharedSecret) + const verify = await jwtVerify(token, sharedSecret, verifyOptions) payload = verify.payload } else { - const verify = await jwtVerify(token, jwkResolver) + const verify = await jwtVerify(token, jwkResolver, verifyOptions) payload = verify.payload } diff --git a/src/create-supabase-context.ts b/src/create-supabase-context.ts index c3759f3..5bdf8cb 100644 --- a/src/create-supabase-context.ts +++ b/src/create-supabase-context.ts @@ -46,6 +46,8 @@ export async function createSupabaseContext( const { data: auth, error } = await verifyAuth(request, { auth: options?.auth, allow: options?.allow, + audience: options?.audience, + issuer: options?.issuer, env: options?.env, }) if (error) { diff --git a/src/middleware/claims/index.test.ts b/src/middleware/claims/index.test.ts index e1d24f2..29cd1f1 100644 --- a/src/middleware/claims/index.test.ts +++ b/src/middleware/claims/index.test.ts @@ -157,4 +157,42 @@ describe('withClaims', () => { message: expect.stringMatching(/^\[@supabase\/server\]/), }) }) + + it('respects audience and issuer options in withClaims', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + publicJwk.kid = 'claims-aud-test' + const testJwks = { keys: [publicJwk] } + + const validToken = await new SignJWT({ + sub: 'user-123', + role: 'authenticated', + }) + .setProtectedHeader({ alg: 'RS256', kid: 'claims-aud-test' }) + .setAudience('expected-audience') + .setIssuer('expected-issuer') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + const matchHandler = withClaims( + { + jwks: testJwks, + audience: 'expected-audience', + issuer: 'expected-issuer', + }, + async (_req, ctx) => Response.json({ claims: ctx.jwtClaims }), + ) + const matchRes = await matchHandler(requestWithToken(validToken)) + expect(matchRes.status).toBe(200) + + const mismatchHandler = withClaims( + { jwks: testJwks, audience: 'wrong-audience' }, + async (_req, ctx) => Response.json({ claims: ctx.jwtClaims }), + ) + const mismatchRes = await mismatchHandler(requestWithToken(validToken)) + expect(mismatchRes.status).toBe(401) + }) }) diff --git a/src/middleware/claims/index.ts b/src/middleware/claims/index.ts index 57667f3..4bc12bc 100644 --- a/src/middleware/claims/index.ts +++ b/src/middleware/claims/index.ts @@ -30,6 +30,10 @@ export interface WithClaimsConfig extends ShortCircuitConfig { * (https endpoint) from the environment. */ jwks?: JSONWebKeySet | URL + /** Expected JWT audience (`aud`) claim to validate. Applies to `user` mode only. */ + audience?: string | string[] + /** Expected JWT issuer (`iss`) claim to validate. Applies to `user` mode only. */ + issuer?: string | string[] } /** @@ -108,7 +112,10 @@ export const withClaims: Middleware< ) } - const verified = await verifyUserJwt(token, jwks) + const verified = await verifyUserJwt(token, jwks, { + audience: config?.audience, + issuer: config?.issuer, + }) if (!verified.ok) { const { failure } = verified return errorResponse( diff --git a/src/middleware/required-claims/index.test.ts b/src/middleware/required-claims/index.test.ts index eefaefd..bc10822 100644 --- a/src/middleware/required-claims/index.test.ts +++ b/src/middleware/required-claims/index.test.ts @@ -292,6 +292,44 @@ describe('withRequiredClaims', () => { expect((await res.json()).code).toBe(JwksNotConfiguredError) } }) + + it('respects audience and issuer options in withRequiredClaims', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + publicJwk.kid = 'req-claims-aud-test' + const testJwks = { keys: [publicJwk] } + + const validToken = await new SignJWT({ + sub: 'user-123', + role: 'authenticated', + }) + .setProtectedHeader({ alg: 'RS256', kid: 'req-claims-aud-test' }) + .setAudience('expected-audience') + .setIssuer('expected-issuer') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + const matchHandler = withRequiredClaims( + { + jwks: testJwks, + audience: 'expected-audience', + issuer: 'expected-issuer', + }, + async (_req, ctx) => Response.json({ sub: ctx.jwtClaims.sub }), + ) + const matchRes = await matchHandler(requestWithToken(validToken)) + expect(matchRes.status).toBe(200) + + const mismatchHandler = withRequiredClaims( + { jwks: testJwks, audience: 'wrong-audience' }, + async (_req, ctx) => Response.json({ sub: ctx.jwtClaims.sub }), + ) + const mismatchRes = await mismatchHandler(requestWithToken(validToken)) + expect(mismatchRes.status).toBe(401) + }) }) }) diff --git a/src/middleware/required-claims/index.ts b/src/middleware/required-claims/index.ts index 0777f86..682c05f 100644 --- a/src/middleware/required-claims/index.ts +++ b/src/middleware/required-claims/index.ts @@ -37,6 +37,10 @@ export interface WithRequiredClaimsConfig extends ShortCircuitConfig { * (https endpoint) from the environment. */ jwks?: JSONWebKeySet | URL + /** Expected JWT audience (`aud`) claim to validate. Applies to `user` mode only. */ + audience?: string | string[] + /** Expected JWT issuer (`iss`) claim to validate. Applies to `user` mode only. */ + issuer?: string | string[] } /** @@ -155,7 +159,10 @@ export const withRequiredClaims: Middleware< ) } - const verified = await verifyUserJwt(token, jwks) + const verified = await verifyUserJwt(token, jwks, { + audience: config?.audience, + issuer: config?.issuer, + }) if (!verified.ok) { const { failure } = verified return errorResponse( diff --git a/src/types.ts b/src/types.ts index dad4b34..af85280 100644 --- a/src/types.ts +++ b/src/types.ts @@ -309,6 +309,10 @@ export interface WithSupabaseConfig extends ShortCircuitConfig { * is where the {@link AuthConfig} ordering rule is enforced. */ allow?: AuthModeWithKey | AuthModeWithKey[] + /** Expected JWT audience (`aud`) claim to validate. Applies to `user` mode only. */ + audience?: string | string[] + /** Expected JWT issuer (`iss`) claim to validate. Applies to `user` mode only. */ + issuer?: string | string[] /** * Override auto-detected environment variables. Useful for testing diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index 5b05e47..78170cc 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { defineMiddleware, getEnv, pipeline } from '@supabase/middleware' import type { FetchHandler } from '@supabase/middleware' +import { exportJWK, generateKeyPair, SignJWT } from 'jose' import { _resetAllowDeprecationWarned } from './core/utils/deprecation.js' import { createSupabaseContext } from './create-supabase-context.js' @@ -847,3 +848,58 @@ describe('withSupabase config without a middleware option', () => { ) }) }) + +describe('withSupabase audience and issuer validation', () => { + it('validates audience and issuer when configured', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + publicJwk.kid = 'with-supabase-aud-test' + const testJwks = { keys: [publicJwk] } + + const token = await new SignJWT({ + sub: 'user-123', + role: 'authenticated', + }) + .setProtectedHeader({ alg: 'RS256', kid: 'with-supabase-aud-test' }) + .setAudience('expected-audience') + .setIssuer('expected-issuer') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + const matchHandler = withSupabase( + { + auth: 'user', + audience: 'expected-audience', + issuer: 'expected-issuer', + env: { ...baseEnv, jwks: testJwks }, + }, + async (_req, ctx) => Response.json({ sub: ctx.userClaims?.id }), + ) + + const matchRes = await matchHandler( + new Request('http://localhost', { + headers: { authorization: `Bearer ${token}` }, + }), + ) + expect(matchRes.status).toBe(200) + + const mismatchHandler = withSupabase( + { + auth: 'user', + audience: 'wrong-audience', + env: { ...baseEnv, jwks: testJwks }, + }, + async (_req, ctx) => Response.json({ sub: ctx.userClaims?.id }), + ) + + const mismatchRes = await mismatchHandler( + new Request('http://localhost', { + headers: { authorization: `Bearer ${token}` }, + }), + ) + expect(mismatchRes.status).toBe(401) + }) +}) From ec6ee59e1bebc5d8ec8417464386a2ad2ed59b6b Mon Sep 17 00:00:00 2001 From: Mandar Joshi Date: Wed, 16 Sep 2026 15:35:15 +0530 Subject: [PATCH 2/3] fix: guard against empty audience and issuer options --- src/core/verify-credentials.test.ts | 26 ++++++++++++++++++++ src/core/verify-user-jwt.ts | 15 +++++++++++ src/middleware/claims/index.test.ts | 8 ++++++ src/middleware/required-claims/index.test.ts | 8 ++++++ src/with-supabase.test.ts | 17 +++++++++++++ 5 files changed, 74 insertions(+) diff --git a/src/core/verify-credentials.test.ts b/src/core/verify-credentials.test.ts index 599a6c1..30e1246 100644 --- a/src/core/verify-credentials.test.ts +++ b/src/core/verify-credentials.test.ts @@ -411,6 +411,32 @@ describe('verifyCredentials', () => { }, ) + it.each([ + ['audience', ''], + ['issuer', ''], + ['audience', ['']], + ['issuer', ['']], + ])('throws when JWT %s option is empty', async (field, value) => { + const token = await new SignJWT({ sub: 'user-123' }) + .setProtectedHeader({ alg: 'RS256', kid: 'asymmetric-key-id' }) + .setAudience('https://test.supabase.co') + .setIssuer('https://test.supabase.co/auth/v1') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + await expect( + verifyCredentials( + { token, apikey: null }, + { + auth: 'user', + [field]: value, + env: makeEnv({ jwks }), + }, + ), + ).rejects.toThrow(`JWT ${field} option cannot be empty`) + }) + it('supports audience and issuer validation with symmetric HS256 keys', async () => { const token = await new SignJWT({ sub: 'user-123' }) .setProtectedHeader({ diff --git a/src/core/verify-user-jwt.ts b/src/core/verify-user-jwt.ts index 011fabe..8ac4126 100644 --- a/src/core/verify-user-jwt.ts +++ b/src/core/verify-user-jwt.ts @@ -252,6 +252,21 @@ export async function verifyUserJwt( } } + if ( + options?.audience === '' || + (options?.audience as unknown) === null || + (Array.isArray(options?.audience) && options.audience.some((a) => !a)) + ) { + throw new Error('JWT audience option cannot be empty') + } + if ( + options?.issuer === '' || + (options?.issuer as unknown) === null || + (Array.isArray(options?.issuer) && options.issuer.some((i) => !i)) + ) { + throw new Error('JWT issuer option cannot be empty') + } + try { const jwkResolver = getJwksResolver(jwks) let payload: JWTPayload | null = null diff --git a/src/middleware/claims/index.test.ts b/src/middleware/claims/index.test.ts index 29cd1f1..365e596 100644 --- a/src/middleware/claims/index.test.ts +++ b/src/middleware/claims/index.test.ts @@ -194,5 +194,13 @@ describe('withClaims', () => { ) const mismatchRes = await mismatchHandler(requestWithToken(validToken)) expect(mismatchRes.status).toBe(401) + + const emptyAudHandler = withClaims( + { jwks: testJwks, audience: '' }, + async (_req, ctx) => Response.json({ claims: ctx.jwtClaims }), + ) + await expect(emptyAudHandler(requestWithToken(validToken))).rejects.toThrow( + 'JWT audience option cannot be empty', + ) }) }) diff --git a/src/middleware/required-claims/index.test.ts b/src/middleware/required-claims/index.test.ts index bc10822..09fc5c8 100644 --- a/src/middleware/required-claims/index.test.ts +++ b/src/middleware/required-claims/index.test.ts @@ -329,6 +329,14 @@ describe('withRequiredClaims', () => { ) const mismatchRes = await mismatchHandler(requestWithToken(validToken)) expect(mismatchRes.status).toBe(401) + + const emptyAudHandler = withRequiredClaims( + { jwks: testJwks, audience: '' }, + async (_req, ctx) => Response.json({ sub: ctx.jwtClaims.sub }), + ) + await expect( + emptyAudHandler(requestWithToken(validToken)), + ).rejects.toThrow('JWT audience option cannot be empty') }) }) }) diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index 78170cc..a91a454 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -901,5 +901,22 @@ describe('withSupabase audience and issuer validation', () => { }), ) expect(mismatchRes.status).toBe(401) + + const emptyAudHandler = withSupabase( + { + auth: 'user', + audience: '', + env: { ...baseEnv, jwks: testJwks }, + }, + async (_req, ctx) => Response.json({ sub: ctx.userClaims?.id }), + ) + + await expect( + emptyAudHandler( + new Request('http://localhost', { + headers: { authorization: `Bearer ${token}` }, + }), + ), + ).rejects.toThrow('JWT audience option cannot be empty') }) }) From 629d95d7f3c6ade83004afef404627e8b80e891c Mon Sep 17 00:00:00 2001 From: Mandar Joshi Date: Wed, 16 Sep 2026 18:36:19 +0530 Subject: [PATCH 3/3] fix: return failure result instead of throwing on empty audience or issuer --- src/core/verify-credentials.test.ts | 26 +++++++++++--------- src/core/verify-user-jwt.ts | 20 +++++++++++++-- src/middleware/claims/index.test.ts | 7 ++++-- src/middleware/required-claims/index.test.ts | 9 ++++--- src/with-supabase.test.ts | 17 +++++++------ 5 files changed, 54 insertions(+), 25 deletions(-) diff --git a/src/core/verify-credentials.test.ts b/src/core/verify-credentials.test.ts index 30e1246..4f585d5 100644 --- a/src/core/verify-credentials.test.ts +++ b/src/core/verify-credentials.test.ts @@ -416,7 +416,7 @@ describe('verifyCredentials', () => { ['issuer', ''], ['audience', ['']], ['issuer', ['']], - ])('throws when JWT %s option is empty', async (field, value) => { + ])('fails when JWT %s option is empty', async (field, value) => { const token = await new SignJWT({ sub: 'user-123' }) .setProtectedHeader({ alg: 'RS256', kid: 'asymmetric-key-id' }) .setAudience('https://test.supabase.co') @@ -425,16 +425,20 @@ describe('verifyCredentials', () => { .setExpirationTime('1h') .sign(privateKey) - await expect( - verifyCredentials( - { token, apikey: null }, - { - auth: 'user', - [field]: value, - env: makeEnv({ jwks }), - }, - ), - ).rejects.toThrow(`JWT ${field} option cannot be empty`) + const result = await verifyCredentials( + { token, apikey: null }, + { + auth: 'user', + [field]: value, + env: makeEnv({ jwks }), + }, + ) + + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidJwtError) + expect(result.error!.message).toContain( + `the configured "${field}" option is empty`, + ) }) it('supports audience and issuer validation with symmetric HS256 keys', async () => { diff --git a/src/core/verify-user-jwt.ts b/src/core/verify-user-jwt.ts index 8ac4126..35efd27 100644 --- a/src/core/verify-user-jwt.ts +++ b/src/core/verify-user-jwt.ts @@ -257,14 +257,30 @@ export async function verifyUserJwt( (options?.audience as unknown) === null || (Array.isArray(options?.audience) && options.audience.some((a) => !a)) ) { - throw new Error('JWT audience option cannot be empty') + return { + ok: false, + failure: { + kind: 'token', + reason: 'the configured "audience" option is empty', + hint: 'Pass a non-empty string or array, or omit the option to skip audience validation.', + jwt, + }, + } } if ( options?.issuer === '' || (options?.issuer as unknown) === null || (Array.isArray(options?.issuer) && options.issuer.some((i) => !i)) ) { - throw new Error('JWT issuer option cannot be empty') + return { + ok: false, + failure: { + kind: 'token', + reason: 'the configured "issuer" option is empty', + hint: 'Pass a non-empty string or array, or omit the option to skip issuer validation.', + jwt, + }, + } } try { diff --git a/src/middleware/claims/index.test.ts b/src/middleware/claims/index.test.ts index 365e596..94df9d4 100644 --- a/src/middleware/claims/index.test.ts +++ b/src/middleware/claims/index.test.ts @@ -199,8 +199,11 @@ describe('withClaims', () => { { jwks: testJwks, audience: '' }, async (_req, ctx) => Response.json({ claims: ctx.jwtClaims }), ) - await expect(emptyAudHandler(requestWithToken(validToken))).rejects.toThrow( - 'JWT audience option cannot be empty', + const emptyAudRes = await emptyAudHandler(requestWithToken(validToken)) + expect(emptyAudRes.status).toBe(401) + const emptyAudBody = await emptyAudRes.json() + expect(emptyAudBody.message).toContain( + 'the configured "audience" option is empty', ) }) }) diff --git a/src/middleware/required-claims/index.test.ts b/src/middleware/required-claims/index.test.ts index 09fc5c8..0899352 100644 --- a/src/middleware/required-claims/index.test.ts +++ b/src/middleware/required-claims/index.test.ts @@ -334,9 +334,12 @@ describe('withRequiredClaims', () => { { jwks: testJwks, audience: '' }, async (_req, ctx) => Response.json({ sub: ctx.jwtClaims.sub }), ) - await expect( - emptyAudHandler(requestWithToken(validToken)), - ).rejects.toThrow('JWT audience option cannot be empty') + const emptyAudRes = await emptyAudHandler(requestWithToken(validToken)) + expect(emptyAudRes.status).toBe(401) + const emptyAudBody = await emptyAudRes.json() + expect(emptyAudBody.message).toContain( + 'the configured "audience" option is empty', + ) }) }) }) diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index a91a454..65492f7 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -911,12 +911,15 @@ describe('withSupabase audience and issuer validation', () => { async (_req, ctx) => Response.json({ sub: ctx.userClaims?.id }), ) - await expect( - emptyAudHandler( - new Request('http://localhost', { - headers: { authorization: `Bearer ${token}` }, - }), - ), - ).rejects.toThrow('JWT audience option cannot be empty') + const emptyAudRes = await emptyAudHandler( + new Request('http://localhost', { + headers: { authorization: `Bearer ${token}` }, + }), + ) + expect(emptyAudRes.status).toBe(401) + const emptyAudBody = await emptyAudRes.json() + expect(emptyAudBody.message).toContain( + 'the configured "audience" option is empty', + ) }) })