From ecfb7383d7981f51f89cb6a115d7b62bd047f171 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Fri, 11 Sep 2026 10:54:52 -0400 Subject: [PATCH 01/13] fix(backend)!: require matching OAuth token audiences --- .changeset/strict-oauth-audience.md | 7 + packages/backend/src/jwt/assertions.ts | 17 +++ packages/backend/src/jwt/verifyMachineJwt.ts | 17 +++ .../src/tokens/__tests__/request.test.ts | 62 +++++++++ .../src/tokens/__tests__/verify.test.ts | 129 +++++++++++++++++- packages/backend/src/tokens/verify.ts | 14 ++ 6 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 .changeset/strict-oauth-audience.md diff --git a/.changeset/strict-oauth-audience.md b/.changeset/strict-oauth-audience.md new file mode 100644 index 00000000000..fa546081eea --- /dev/null +++ b/.changeset/strict-oauth-audience.md @@ -0,0 +1,7 @@ +--- +'@clerk/backend': major +--- + +OAuth access token verification now requires a matching `aud` when a non-empty `audience` option is configured, for both opaque tokens and JWTs. Tokens with a missing, empty, malformed, or mismatched audience are rejected. + +Applications that configure `audience` must issue OAuth tokens with a matching audience before upgrading. Session JWT and M2M token verification behavior is unchanged. diff --git a/packages/backend/src/jwt/assertions.ts b/packages/backend/src/jwt/assertions.ts index 8ab74096f7e..e686ff15977 100644 --- a/packages/backend/src/jwt/assertions.ts +++ b/packages/backend/src/jwt/assertions.ts @@ -47,6 +47,23 @@ export const assertAudienceClaim = (aud?: unknown, audience?: unknown) => { } }; +export const assertOAuthAudienceClaim = (aud?: unknown, audience?: string | string[]) => { + if (![audience].flat().some(a => !!a)) { + return; + } + + const hasAudience = + (typeof aud === 'string' && aud.length > 0) || (isArrayString(aud) && aud.every(a => a.length > 0)); + if (!hasAudience) { + throw new TokenVerificationError({ + reason: TokenVerificationErrorReason.TokenVerificationFailed, + message: `Invalid OAuth audience claim (aud) ${JSON.stringify(aud)}. Expected a non-empty string or a non-empty array of non-empty strings.`, + }); + } + + assertAudienceClaim(aud, audience); +}; + export const assertHeaderType = (typ?: unknown, allowedTypes?: string | string[]) => { if (typeof typ === 'undefined' && typeof allowedTypes === 'undefined') { return; diff --git a/packages/backend/src/jwt/verifyMachineJwt.ts b/packages/backend/src/jwt/verifyMachineJwt.ts index 847660e0593..a269fc666ec 100644 --- a/packages/backend/src/jwt/verifyMachineJwt.ts +++ b/packages/backend/src/jwt/verifyMachineJwt.ts @@ -14,8 +14,10 @@ import type { LoadClerkJWKFromRemoteOptions } from '../tokens/keys'; import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from '../tokens/keys'; import { OAUTH_ACCESS_TOKEN_TYPES } from '../tokens/machine'; import { TokenType } from '../tokens/tokenTypes'; +import { assertOAuthAudienceClaim } from './assertions'; export type JwtMachineVerifyOptions = Pick & { + audience?: string | string[]; jwtKey?: string; clockSkewInMs?: number; }; @@ -131,6 +133,21 @@ export async function verifyOAuthJwt( return { data: undefined, tokenType: TokenType.OAuthToken, errors: [result.error] }; } + try { + assertOAuthAudienceClaim(result.payload.aud, options.audience); + } catch (error) { + return { + data: undefined, + tokenType: TokenType.OAuthToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: (error as Error).message, + }), + ], + }; + } + return { data: IdPOAuthAccessToken.fromJwtPayload(result.payload, options.clockSkewInMs), tokenType: TokenType.OAuthToken, diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 852e4ece880..6abb8ca5aa2 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1598,6 +1598,68 @@ describe('tokens.authenticateRequest(options)', () => { }); }); + test.each(['oauth_token', 'any'] as const)( + 'rejects an opaque OAuth audience mismatch when acceptsToken is %s', + async acceptsToken => { + server.use( + http.post(mockMachineAuthResponses.oauth_token.endpoint, () => { + return HttpResponse.json({ + ...mockVerificationResults.oauth_token, + aud: 'https://other.example.com', + }); + }), + ); + + const request = mockRequest({ authorization: `Bearer ${mockTokens.oauth_token}` }); + const requestState = await authenticateRequest( + request, + mockOptions({ acceptsToken, audience: 'https://resource.example.com' }), + ); + + expect(requestState).toBeMachineUnauthenticated({ + tokenType: 'oauth_token', + reason: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: + 'Invalid JWT audience claim (aud) "https://other.example.com". Is not included in "["https://resource.example.com"]". (code=token-verification-failed, status=n/a)', + }); + expect(requestState.toAuth()).toBeMachineUnauthenticatedToAuth({ + tokenType: 'oauth_token', + isAuthenticated: false, + }); + }, + ); + + describe.each(['opaque', 'JWT'] as const)('%s OAuth token without aud', format => { + test.each(['oauth_token', 'any'] as const)( + 'rejects a configured audience when acceptsToken is %s', + async acceptsToken => { + server.use( + http.post(mockMachineAuthResponses.oauth_token.endpoint, () => { + return HttpResponse.json(mockVerificationResults.oauth_token); + }), + http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)), + ); + const token = format === 'opaque' ? mockTokens.oauth_token : mockSignedOAuthAccessTokenJwt; + const request = mockRequest({ authorization: `Bearer ${token}` }); + const requestState = await authenticateRequest( + request, + mockOptions({ acceptsToken, audience: 'https://resource.example.com' }), + ); + + expect(requestState).toBeMachineUnauthenticated({ + tokenType: 'oauth_token', + reason: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: + 'Invalid OAuth audience claim (aud) undefined. Expected a non-empty string or a non-empty array of non-empty strings. (code=token-verification-failed, status=n/a)', + }); + expect(requestState.toAuth()).toBeMachineUnauthenticatedToAuth({ + tokenType: 'oauth_token', + isAuthenticated: false, + }); + }, + ); + }); + test('accepts machine secret when verifying machine-to-machine token', async () => { server.use( http.post(mockMachineAuthResponses.m2m_token.endpoint, ({ request }) => { diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index a50499d9fef..a364171071a 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -2,6 +2,7 @@ import { http, HttpResponse } from 'msw'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { APIKey, IdPOAuthAccessToken, M2MToken } from '../../api'; +import { MachineTokenVerificationError, MachineTokenVerificationErrorCode } from '../../errors'; import { createJwt, mockJwks, @@ -32,7 +33,10 @@ async function createSignedOAuthJwt( return data!; } -async function createSignedM2MJwt(payload = mockM2MJwtPayload, cat: string | undefined = JWT_CATEGORY_M2M_TOKEN) { +async function createSignedM2MJwt( + payload: Record = mockM2MJwtPayload, + cat: string | undefined = JWT_CATEGORY_M2M_TOKEN, +) { const { data } = await signJwt(payload, signingJwks, { algorithm: 'RS256', header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', ...(cat !== undefined ? { cat } : {}) }, @@ -69,6 +73,19 @@ describe('tokens.verify(token, options)', () => { expect(data).toEqual(mockJwtPayload); }); + it('continues accepting session JWTs without aud when audience is configured', async () => { + server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks))); + + const result = await verifyToken(mockJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + audience: 'https://resource.example.com', + }); + + expect(result.data).toEqual(mockJwtPayload); + expect(result.errors).toBeUndefined(); + }); + it('verifies the token by fetching the JWKs from Backend API when secretKey is provided', async () => { server.use( http.get( @@ -263,6 +280,101 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(data.aud).toEqual(aud); }); + describe.each(['opaque', 'at+jwt', 'application/at+jwt'] as const)('%s OAuth token audience verification', format => { + const audience = 'https://resource.example.com'; + const otherAudience = 'https://other.example.com'; + + beforeEach(() => { + vi.setSystemTime(new Date(mockOAuthAccessTokenJwtPayload.iat * 1000)); + }); + + async function verifyWithAudience(aud: unknown, audience?: string | string[]) { + let token: string; + if (format === 'opaque') { + token = 'oat_8XOIucKvqHVr5tYP123456789abcdefghij'; + server.use( + http.post('https://api.clerk.test/oauth_applications/access_tokens/verify', () => { + return HttpResponse.json({ ...mockVerificationResults.oauth_token, aud }); + }), + ); + } else { + server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks))); + token = await createSignedOAuthJwt({ ...mockOAuthAccessTokenJwtPayload, aud }, format); + } + + return verifyMachineAuthToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + audience, + }); + } + + it.each([ + { aud: audience, audience }, + { aud: audience, audience: [otherAudience, audience] }, + { aud: [otherAudience, audience], audience }, + { aud: [otherAudience, audience], audience: [audience] }, + { aud: undefined, audience: undefined }, + { aud: undefined, audience: '' }, + { aud: undefined, audience: [] }, + { aud: '', audience: undefined }, + { aud: [], audience: undefined }, + { aud: otherAudience, audience: undefined }, + { aud: otherAudience, audience: '' }, + { aud: otherAudience, audience: [] }, + ])('accepts aud=$aud with audience=$audience', async ({ aud, audience }) => { + const result = await verifyWithAudience(aud, audience); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.data).toBeDefined(); + expect((result.data as IdPOAuthAccessToken).aud).toEqual(aud); + expect(result.errors).toBeUndefined(); + }); + + it.each([ + { aud: otherAudience, audience }, + { aud: otherAudience, audience: [audience] }, + { aud: [otherAudience], audience }, + { aud: [otherAudience], audience: [audience] }, + { aud: `${audience}/other`, audience }, + { aud: audience.toUpperCase(), audience }, + ])('rejects aud=$aud with audience=$audience', async ({ aud, audience }) => { + const result = await verifyWithAudience(aud, audience); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.data).toBeUndefined(); + expect(result.errors).toHaveLength(1); + expect(result.errors![0]).toBeInstanceOf(MachineTokenVerificationError); + expect(result.errors![0]).toMatchObject({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: expect.stringContaining('Invalid JWT audience claim'), + }); + }); + + it.each([undefined, null, '', [], [''], [audience, ''], 42, true, {}, [audience, 42]].map(aud => ({ aud })))( + 'rejects missing, empty, or malformed aud=$aud when audience is configured', + async ({ aud }) => { + const result = await verifyWithAudience(aud, audience); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.data).toBeUndefined(); + expect(result.errors).toHaveLength(1); + expect(result.errors![0]).toBeInstanceOf(MachineTokenVerificationError); + expect(result.errors![0]).toMatchObject({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: expect.stringContaining('Invalid OAuth audience claim'), + }); + }, + ); + + it('rejects missing aud when audience is an array', async () => { + const result = await verifyWithAudience(undefined, [audience]); + + expect(result.data).toBeUndefined(); + expect(result.errors?.[0].code).toBe(MachineTokenVerificationErrorCode.TokenVerificationFailed); + }); + }); + describe('handles API errors for API keys', () => { it('handles invalid token', async () => { const token = 'ak_invalid_token'; @@ -677,6 +789,21 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(data.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); }); + it('continues accepting M2M JWTs without aud when audience is configured', async () => { + server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks))); + const token = await createSignedM2MJwt({ ...mockM2MJwtPayload, aud: undefined }); + + const result = await verifyMachineAuthToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + audience: 'https://resource.example.com', + }); + + expect(result.tokenType).toBe('m2m_token'); + expect(result.data).toBeDefined(); + expect(result.errors).toBeUndefined(); + }); + it('rejects M2M JWT with alg: none', async () => { server.use( http.get( diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index 3921b6ca04c..66e4e879201 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -11,6 +11,7 @@ import { TokenVerificationErrorReason, } from '../errors'; import type { VerifyJwtOptions } from '../jwt'; +import { assertOAuthAudienceClaim } from '../jwt/assertions'; import type { JwtReturnType, MachineTokenReturnType } from '../jwt/types'; import { decodeJwt, verifyJwt } from '../jwt/verifyJwt'; import { verifyM2MJwt, verifyOAuthJwt } from '../jwt/verifyMachineJwt'; @@ -228,8 +229,21 @@ async function verifyOAuthToken( try { const client = createBackendApiClient(options); const verifiedToken = await client.idPOAuthAccessToken.verify(accessToken); + assertOAuthAudienceClaim(verifiedToken.aud, options.audience); return { data: verifiedToken, tokenType: TokenType.OAuthToken, errors: undefined }; } catch (err: any) { + if (err instanceof TokenVerificationError) { + return { + data: undefined, + tokenType: TokenType.OAuthToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: err.message, + }), + ], + }; + } return handleClerkAPIError(TokenType.OAuthToken, err, 'OAuth token not found'); } } From e1773cc37b1666ffd750fae5700487e553a2675e Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Mon, 21 Sep 2026 13:53:54 -0400 Subject: [PATCH 02/13] fix(backend): unify OAuth audience verification --- packages/backend/src/jwt/assertions.ts | 21 ++++++++++--- packages/backend/src/jwt/verifyMachineJwt.ts | 31 +++++++++++++------ .../src/tokens/__tests__/request.test.ts | 2 +- .../src/tokens/__tests__/verify.test.ts | 5 ++- packages/backend/src/tokens/verify.ts | 27 ++++++---------- 5 files changed, 50 insertions(+), 36 deletions(-) diff --git a/packages/backend/src/jwt/assertions.ts b/packages/backend/src/jwt/assertions.ts index e686ff15977..0d6e4729ba1 100644 --- a/packages/backend/src/jwt/assertions.ts +++ b/packages/backend/src/jwt/assertions.ts @@ -7,6 +7,10 @@ const isArrayString = (s: unknown): s is string[] => { return Array.isArray(s) && s.length > 0 && s.every(a => typeof a === 'string'); }; +const isNonEmptyArrayString = (s: unknown): s is string[] => { + return isArrayString(s) && s.every(a => a.length > 0); +}; + export const assertAudienceClaim = (aud?: unknown, audience?: unknown) => { const audienceList = [audience].flat().filter(a => !!a); const audList = [aud].flat().filter(a => !!a); @@ -48,20 +52,27 @@ export const assertAudienceClaim = (aud?: unknown, audience?: unknown) => { }; export const assertOAuthAudienceClaim = (aud?: unknown, audience?: string | string[]) => { - if (![audience].flat().some(a => !!a)) { + const audienceList = [audience].flat().filter((a): a is string => typeof a === 'string' && a.length > 0); + if (audienceList.length === 0) { return; } - const hasAudience = - (typeof aud === 'string' && aud.length > 0) || (isArrayString(aud) && aud.every(a => a.length > 0)); - if (!hasAudience) { + const audList = typeof aud === 'string' && aud.length > 0 ? [aud] : isNonEmptyArrayString(aud) ? aud : undefined; + if (!audList) { throw new TokenVerificationError({ reason: TokenVerificationErrorReason.TokenVerificationFailed, message: `Invalid OAuth audience claim (aud) ${JSON.stringify(aud)}. Expected a non-empty string or a non-empty array of non-empty strings.`, }); } - assertAudienceClaim(aud, audience); + if (!audList.some(a => audienceList.includes(a))) { + throw new TokenVerificationError({ + reason: TokenVerificationErrorReason.TokenVerificationFailed, + message: `Invalid OAuth audience claim (aud) ${JSON.stringify(aud)}. Is not included in "${JSON.stringify( + audienceList, + )}".`, + }); + } }; export const assertHeaderType = (typ?: unknown, allowedTypes?: string | string[]) => { diff --git a/packages/backend/src/jwt/verifyMachineJwt.ts b/packages/backend/src/jwt/verifyMachineJwt.ts index a269fc666ec..4cbd2ca153d 100644 --- a/packages/backend/src/jwt/verifyMachineJwt.ts +++ b/packages/backend/src/jwt/verifyMachineJwt.ts @@ -22,6 +22,22 @@ export type JwtMachineVerifyOptions = Pick> { - const result = await resolveKeyAndVerifyJwt(token, decoded.header.kid, options, OAUTH_ACCESS_TOKEN_TYPES); + const { audience, ...jwtOptions } = options; + const result = await resolveKeyAndVerifyJwt(token, decoded.header.kid, jwtOptions, OAUTH_ACCESS_TOKEN_TYPES); if ('error' in result) { return { data: undefined, tokenType: TokenType.OAuthToken, errors: [result.error] }; } - try { - assertOAuthAudienceClaim(result.payload.aud, options.audience); - } catch (error) { + const audienceError = getOAuthAudienceVerificationError(result.payload.aud, audience); + if (audienceError) { return { data: undefined, tokenType: TokenType.OAuthToken, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - message: (error as Error).message, - }), - ], + errors: [audienceError], }; } diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 6abb8ca5aa2..23ab65d190c 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1620,7 +1620,7 @@ describe('tokens.authenticateRequest(options)', () => { tokenType: 'oauth_token', reason: MachineTokenVerificationErrorCode.TokenVerificationFailed, message: - 'Invalid JWT audience claim (aud) "https://other.example.com". Is not included in "["https://resource.example.com"]". (code=token-verification-failed, status=n/a)', + 'Invalid OAuth audience claim (aud) "https://other.example.com". Is not included in "["https://resource.example.com"]". (code=token-verification-failed, status=n/a)', }); expect(requestState.toAuth()).toBeMachineUnauthenticatedToAuth({ tokenType: 'oauth_token', diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index a364171071a..d9b2a1bb39a 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -327,7 +327,6 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.tokenType).toBe('oauth_token'); expect(result.data).toBeDefined(); - expect((result.data as IdPOAuthAccessToken).aud).toEqual(aud); expect(result.errors).toBeUndefined(); }); @@ -347,7 +346,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.errors![0]).toBeInstanceOf(MachineTokenVerificationError); expect(result.errors![0]).toMatchObject({ code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - message: expect.stringContaining('Invalid JWT audience claim'), + message: expect.stringContaining('Invalid OAuth audience claim'), }); }); @@ -747,7 +746,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.data).toBeUndefined(); expect(result.errors).toHaveLength(1); expect(result.errors![0]).toMatchInlineSnapshot( - `[MachineTokenVerificationError: Invalid JWT audience claim (aud) "https://attacker.example.com". Is not included in "["https://my-resource.example.com"]".]`, + `[MachineTokenVerificationError: Invalid OAuth audience claim (aud) "https://attacker.example.com". Is not included in "["https://my-resource.example.com"]".]`, ); }); }); diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index 66e4e879201..dc611fc7a42 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -11,10 +11,9 @@ import { TokenVerificationErrorReason, } from '../errors'; import type { VerifyJwtOptions } from '../jwt'; -import { assertOAuthAudienceClaim } from '../jwt/assertions'; import type { JwtReturnType, MachineTokenReturnType } from '../jwt/types'; import { decodeJwt, verifyJwt } from '../jwt/verifyJwt'; -import { verifyM2MJwt, verifyOAuthJwt } from '../jwt/verifyMachineJwt'; +import { getOAuthAudienceVerificationError, verifyM2MJwt, verifyOAuthJwt } from '../jwt/verifyMachineJwt'; import { JWT_CATEGORY_M2M_TOKEN } from './jwtCategories'; import type { LoadClerkJWKFromRemoteOptions } from './keys'; import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys'; @@ -226,26 +225,20 @@ async function verifyOAuthToken( accessToken: string, options: VerifyTokenOptions, ): Promise> { + let verifiedToken: IdPOAuthAccessToken; try { const client = createBackendApiClient(options); - const verifiedToken = await client.idPOAuthAccessToken.verify(accessToken); - assertOAuthAudienceClaim(verifiedToken.aud, options.audience); - return { data: verifiedToken, tokenType: TokenType.OAuthToken, errors: undefined }; + verifiedToken = await client.idPOAuthAccessToken.verify(accessToken); } catch (err: any) { - if (err instanceof TokenVerificationError) { - return { - data: undefined, - tokenType: TokenType.OAuthToken, - errors: [ - new MachineTokenVerificationError({ - code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - message: err.message, - }), - ], - }; - } return handleClerkAPIError(TokenType.OAuthToken, err, 'OAuth token not found'); } + + const audienceError = getOAuthAudienceVerificationError(verifiedToken.aud, options.audience); + if (audienceError) { + return { data: undefined, tokenType: TokenType.OAuthToken, errors: [audienceError] }; + } + + return { data: verifiedToken, tokenType: TokenType.OAuthToken, errors: undefined }; } async function verifyAPIKey( From 6520ceffac5b9059e517db7c4916e3a3979b8113 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Mon, 21 Sep 2026 15:19:10 -0400 Subject: [PATCH 03/13] fix(backend): expose OAuth audience verification options --- .../__tests__/IdPOAuthAccessTokenApi.test.ts | 65 +++++++++++++++++++ .../api/endpoints/IdPOAuthAccessTokenApi.ts | 8 ++- packages/backend/src/tokens/verify.ts | 25 ++++--- 3 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 packages/backend/src/api/__tests__/IdPOAuthAccessTokenApi.test.ts diff --git a/packages/backend/src/api/__tests__/IdPOAuthAccessTokenApi.test.ts b/packages/backend/src/api/__tests__/IdPOAuthAccessTokenApi.test.ts new file mode 100644 index 00000000000..9d7839a2236 --- /dev/null +++ b/packages/backend/src/api/__tests__/IdPOAuthAccessTokenApi.test.ts @@ -0,0 +1,65 @@ +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { server, validateHeaders } from '../../mock-server'; +import { createBackendApiClient } from '../factory'; + +describe('IdPOAuthAccessToken', () => { + const accessToken = 'oat_xxxxx'; + const audience = 'https://resource.example.com'; + const tokenResponse = { + object: 'clerk_idp_oauth_access_token', + id: accessToken, + client_id: 'client_xxxxx', + type: 'oauth:access_token', + subject: 'user_xxxxx', + scopes: ['read:foo'], + revoked: false, + revocation_reason: null, + expired: false, + expiration: null, + created_at: 1753743316590, + updated_at: 1753743316590, + }; + + it('verifies an opaque OAuth token with a matching audience', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_xxxxx', + }); + + server.use( + http.post( + 'https://api.clerk.test/oauth_applications/access_tokens/verify', + validateHeaders(async ({ request }) => { + expect(request.headers.get('Authorization')).toBe('Bearer sk_xxxxx'); + const body = (await request.json()) as Record; + expect(body.access_token).toBe(accessToken); + return HttpResponse.json({ ...tokenResponse, aud: [audience] }); + }), + ), + ); + + const response = await apiClient.idPOAuthAccessToken.verify(accessToken, { audience }); + + expect(response.id).toBe(accessToken); + expect(response.aud).toEqual([audience]); + }); + + it('rejects an opaque OAuth token with a mismatched audience', async () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_xxxxx', + }); + + server.use( + http.post('https://api.clerk.test/oauth_applications/access_tokens/verify', () => + HttpResponse.json({ ...tokenResponse, aud: ['https://other.example.com'] }), + ), + ); + + await expect(apiClient.idPOAuthAccessToken.verify(accessToken, { audience })).rejects.toThrow( + 'Invalid OAuth audience claim', + ); + }); +}); diff --git a/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts b/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts index fa84a926c85..166e23f7340 100644 --- a/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts +++ b/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts @@ -1,3 +1,4 @@ +import { assertOAuthAudienceClaim } from '../../jwt/assertions'; import { joinPaths } from '../../util/path'; import type { IdPOAuthAccessToken } from '../resources'; import { AbstractAPI } from './AbstractApi'; @@ -5,11 +6,14 @@ import { AbstractAPI } from './AbstractApi'; const basePath = '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/oauth_applications/access_tokens'; export class IdPOAuthAccessTokenApi extends AbstractAPI { - async verify(accessToken: string) { - return this.request({ + async verify(accessToken: string, options: { audience?: string | string[] } = {}) { + const verifiedToken = await this.request({ method: 'POST', path: joinPaths(basePath, 'verify'), bodyParams: { access_token: accessToken }, }); + + assertOAuthAudienceClaim(verifiedToken.aud, options.audience); + return verifiedToken; } } diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index dc611fc7a42..b20387f93d4 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -13,7 +13,7 @@ import { import type { VerifyJwtOptions } from '../jwt'; import type { JwtReturnType, MachineTokenReturnType } from '../jwt/types'; import { decodeJwt, verifyJwt } from '../jwt/verifyJwt'; -import { getOAuthAudienceVerificationError, verifyM2MJwt, verifyOAuthJwt } from '../jwt/verifyMachineJwt'; +import { verifyM2MJwt, verifyOAuthJwt } from '../jwt/verifyMachineJwt'; import { JWT_CATEGORY_M2M_TOKEN } from './jwtCategories'; import type { LoadClerkJWKFromRemoteOptions } from './keys'; import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys'; @@ -225,20 +225,25 @@ async function verifyOAuthToken( accessToken: string, options: VerifyTokenOptions, ): Promise> { - let verifiedToken: IdPOAuthAccessToken; try { const client = createBackendApiClient(options); - verifiedToken = await client.idPOAuthAccessToken.verify(accessToken); + const verifiedToken = await client.idPOAuthAccessToken.verify(accessToken, { audience: options.audience }); + return { data: verifiedToken, tokenType: TokenType.OAuthToken, errors: undefined }; } catch (err: any) { + if (err instanceof TokenVerificationError) { + return { + data: undefined, + tokenType: TokenType.OAuthToken, + errors: [ + new MachineTokenVerificationError({ + code: MachineTokenVerificationErrorCode.TokenVerificationFailed, + message: err.message, + }), + ], + }; + } return handleClerkAPIError(TokenType.OAuthToken, err, 'OAuth token not found'); } - - const audienceError = getOAuthAudienceVerificationError(verifiedToken.aud, options.audience); - if (audienceError) { - return { data: undefined, tokenType: TokenType.OAuthToken, errors: [audienceError] }; - } - - return { data: verifiedToken, tokenType: TokenType.OAuthToken, errors: undefined }; } async function verifyAPIKey( From e171727d64439f83fe76d5adb7ac378cc16ee1a7 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Mon, 21 Sep 2026 15:37:25 -0400 Subject: [PATCH 04/13] fix(backend): clarify OAuth audience mismatch --- .../backend/src/api/__tests__/IdPOAuthAccessTokenApi.test.ts | 2 +- packages/backend/src/jwt/assertions.ts | 4 ++-- packages/backend/src/tokens/__tests__/request.test.ts | 2 +- packages/backend/src/tokens/__tests__/verify.test.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/api/__tests__/IdPOAuthAccessTokenApi.test.ts b/packages/backend/src/api/__tests__/IdPOAuthAccessTokenApi.test.ts index 9d7839a2236..e6dfc86c2e0 100644 --- a/packages/backend/src/api/__tests__/IdPOAuthAccessTokenApi.test.ts +++ b/packages/backend/src/api/__tests__/IdPOAuthAccessTokenApi.test.ts @@ -59,7 +59,7 @@ describe('IdPOAuthAccessToken', () => { ); await expect(apiClient.idPOAuthAccessToken.verify(accessToken, { audience })).rejects.toThrow( - 'Invalid OAuth audience claim', + 'OAuth audience mismatch. Verification expected audience ["https://resource.example.com"], but incoming token has aud ["https://other.example.com"].', ); }); }); diff --git a/packages/backend/src/jwt/assertions.ts b/packages/backend/src/jwt/assertions.ts index 0d6e4729ba1..9bf4f774dec 100644 --- a/packages/backend/src/jwt/assertions.ts +++ b/packages/backend/src/jwt/assertions.ts @@ -68,9 +68,9 @@ export const assertOAuthAudienceClaim = (aud?: unknown, audience?: string | stri if (!audList.some(a => audienceList.includes(a))) { throw new TokenVerificationError({ reason: TokenVerificationErrorReason.TokenVerificationFailed, - message: `Invalid OAuth audience claim (aud) ${JSON.stringify(aud)}. Is not included in "${JSON.stringify( + message: `OAuth audience mismatch. Verification expected audience ${JSON.stringify( audienceList, - )}".`, + )}, but incoming token has aud ${JSON.stringify(aud)}.`, }); } }; diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 23ab65d190c..27ab1a55047 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1620,7 +1620,7 @@ describe('tokens.authenticateRequest(options)', () => { tokenType: 'oauth_token', reason: MachineTokenVerificationErrorCode.TokenVerificationFailed, message: - 'Invalid OAuth audience claim (aud) "https://other.example.com". Is not included in "["https://resource.example.com"]". (code=token-verification-failed, status=n/a)', + 'OAuth audience mismatch. Verification expected audience ["https://resource.example.com"], but incoming token has aud "https://other.example.com". (code=token-verification-failed, status=n/a)', }); expect(requestState.toAuth()).toBeMachineUnauthenticatedToAuth({ tokenType: 'oauth_token', diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index d9b2a1bb39a..5cbc3628071 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -346,7 +346,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.errors![0]).toBeInstanceOf(MachineTokenVerificationError); expect(result.errors![0]).toMatchObject({ code: MachineTokenVerificationErrorCode.TokenVerificationFailed, - message: expect.stringContaining('Invalid OAuth audience claim'), + message: expect.stringContaining('OAuth audience mismatch'), }); }); @@ -746,7 +746,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.data).toBeUndefined(); expect(result.errors).toHaveLength(1); expect(result.errors![0]).toMatchInlineSnapshot( - `[MachineTokenVerificationError: Invalid OAuth audience claim (aud) "https://attacker.example.com". Is not included in "["https://my-resource.example.com"]".]`, + `[MachineTokenVerificationError: OAuth audience mismatch. Verification expected audience ["https://my-resource.example.com"], but incoming token has aud "https://attacker.example.com".]`, ); }); }); From 838e1b89f1bbfd64cdae26e4e56ebc469f660ce2 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Mon, 21 Sep 2026 16:25:31 -0400 Subject: [PATCH 05/13] clean up --- packages/backend/src/jwt/assertions.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/jwt/assertions.ts b/packages/backend/src/jwt/assertions.ts index 9bf4f774dec..98d9b1ce701 100644 --- a/packages/backend/src/jwt/assertions.ts +++ b/packages/backend/src/jwt/assertions.ts @@ -7,10 +7,6 @@ const isArrayString = (s: unknown): s is string[] => { return Array.isArray(s) && s.length > 0 && s.every(a => typeof a === 'string'); }; -const isNonEmptyArrayString = (s: unknown): s is string[] => { - return isArrayString(s) && s.every(a => a.length > 0); -}; - export const assertAudienceClaim = (aud?: unknown, audience?: unknown) => { const audienceList = [audience].flat().filter(a => !!a); const audList = [aud].flat().filter(a => !!a); @@ -51,21 +47,23 @@ export const assertAudienceClaim = (aud?: unknown, audience?: unknown) => { } }; -export const assertOAuthAudienceClaim = (aud?: unknown, audience?: string | string[]) => { - const audienceList = [audience].flat().filter((a): a is string => typeof a === 'string' && a.length > 0); +export const assertOAuthAudienceClaim = (aud?: string[] | undefined, audience?: string | string[]) => { + // if no expected audiences, nothing to check + const audienceList = [audience].flat().filter(a => !!a); if (audienceList.length === 0) { return; } - const audList = typeof aud === 'string' && aud.length > 0 ? [aud] : isNonEmptyArrayString(aud) ? aud : undefined; - if (!audList) { + // we are now expecting audiences; + const audFromToken = aud ? [aud].flat() : undefined; + if (!audFromToken) { throw new TokenVerificationError({ reason: TokenVerificationErrorReason.TokenVerificationFailed, message: `Invalid OAuth audience claim (aud) ${JSON.stringify(aud)}. Expected a non-empty string or a non-empty array of non-empty strings.`, }); } - if (!audList.some(a => audienceList.includes(a))) { + if (!audFromToken.some(a => audienceList.includes(a))) { throw new TokenVerificationError({ reason: TokenVerificationErrorReason.TokenVerificationFailed, message: `OAuth audience mismatch. Verification expected audience ${JSON.stringify( From 2b28f055c0cb79df1e8e031f9da7e0713f1aaa0f Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Mon, 21 Sep 2026 16:43:33 -0400 Subject: [PATCH 06/13] soften types --- packages/backend/src/jwt/assertions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/jwt/assertions.ts b/packages/backend/src/jwt/assertions.ts index 98d9b1ce701..b82bdbaf0c3 100644 --- a/packages/backend/src/jwt/assertions.ts +++ b/packages/backend/src/jwt/assertions.ts @@ -47,7 +47,7 @@ export const assertAudienceClaim = (aud?: unknown, audience?: unknown) => { } }; -export const assertOAuthAudienceClaim = (aud?: string[] | undefined, audience?: string | string[]) => { +export const assertOAuthAudienceClaim = (aud: unknown, audience?: string | string[]) => { // if no expected audiences, nothing to check const audienceList = [audience].flat().filter(a => !!a); if (audienceList.length === 0) { @@ -55,7 +55,7 @@ export const assertOAuthAudienceClaim = (aud?: string[] | undefined, audience?: } // we are now expecting audiences; - const audFromToken = aud ? [aud].flat() : undefined; + const audFromToken = aud ? [aud as string[] | undefined].flat() : undefined; if (!audFromToken) { throw new TokenVerificationError({ reason: TokenVerificationErrorReason.TokenVerificationFailed, From 7c5c2796b6019f021d81ee0e1b8714afcb887d73 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Mon, 21 Sep 2026 16:47:47 -0400 Subject: [PATCH 07/13] pr feedback: - https://github.com/clerk/javascript/pull/9724#discussion_r4066184536 --- packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts b/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts index 166e23f7340..3b87daeceef 100644 --- a/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts +++ b/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts @@ -6,7 +6,7 @@ import { AbstractAPI } from './AbstractApi'; const basePath = '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/oauth_applications/access_tokens'; export class IdPOAuthAccessTokenApi extends AbstractAPI { - async verify(accessToken: string, options: { audience?: string | string[] } = {}) { + async verify(accessToken: string, options: { audience?: string | string[] } = {}): Promise { const verifiedToken = await this.request({ method: 'POST', path: joinPaths(basePath, 'verify'), From fcfd5d68576acf287a2f8d530164e4e7676fda54 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Mon, 21 Sep 2026 17:00:11 -0400 Subject: [PATCH 08/13] JSdoc comments --- .../api/endpoints/IdPOAuthAccessTokenApi.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts b/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts index 3b87daeceef..f9da595ef3e 100644 --- a/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts +++ b/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts @@ -6,6 +6,24 @@ import { AbstractAPI } from './AbstractApi'; const basePath = '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/oauth_applications/access_tokens'; export class IdPOAuthAccessTokenApi extends AbstractAPI { + /** + * Verifies an OAuth access token with the Clerk Backend API. If an audience is provided, at least one expected audience must match the token's `aud` claim. Without an audience, the SDK does not check the `aud` claim. + * + * @param accessToken - The OAuth access token to verify. + * @param options - Optional verification options. `audience` can be one expected audience or an array of acceptable audiences. + * @returns The verified `IdPOAuthAccessToken` resource, including its audience when present. + * @throws `ClerkAPIResponseError` if the Backend API rejects the token or request. + * @throws `TokenVerificationError` if an expected audience is provided but the token has no `aud` claim or none of its audiences match. + * + * @example + * ### Verify a token for an API + * + * ```ts + * const token = await clerkClient.idPOAuthAccessToken.verify(accessToken, { + * audience: 'https://api.example.com', + * }); + * ``` + */ async verify(accessToken: string, options: { audience?: string | string[] } = {}): Promise { const verifiedToken = await this.request({ method: 'POST', From bc2681562ff424953260a7ebcb952d42ae30c2b6 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Mon, 21 Sep 2026 17:19:26 -0400 Subject: [PATCH 09/13] fix tests --- .../src/jwt/__tests__/assertions.test.ts | 23 +++++++++++++++++++ packages/backend/src/jwt/assertions.ts | 6 ++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/jwt/__tests__/assertions.test.ts b/packages/backend/src/jwt/__tests__/assertions.test.ts index c61f09bf9e2..06cda659840 100644 --- a/packages/backend/src/jwt/__tests__/assertions.test.ts +++ b/packages/backend/src/jwt/__tests__/assertions.test.ts @@ -8,6 +8,7 @@ import { assertHeaderAlgorithm, assertHeaderType, assertIssuedAtClaim, + assertOAuthAudienceClaim, assertSubClaim, } from '../assertions'; @@ -112,6 +113,28 @@ describe('assertAudienceClaim(audience?, aud?)', () => { }); }); +describe('assertOAuthAudienceClaim(aud, audience?)', () => { + const audience = 'https://resource.example.com'; + const otherAudience = 'https://other.example.com'; + + it.each([ + { aud: audience, expected: audience }, + { aud: [otherAudience, audience], expected: audience }, + { aud: audience, expected: [otherAudience, audience] }, + { aud: [otherAudience, audience], expected: [audience] }, + ])('accepts aud=$aud with expected audience=$expected', ({ aud, expected }) => { + expect(() => assertOAuthAudienceClaim(aud, expected)).not.toThrow(); + }); + + it.each([undefined, null, '', []])('rejects missing or empty aud=%j when an audience is expected', aud => { + expect(() => assertOAuthAudienceClaim(aud, audience)).toThrow('Invalid OAuth audience claim'); + }); + + it.each([undefined, '', []])('skips audience validation when expected audience=%j', expected => { + expect(() => assertOAuthAudienceClaim(undefined, expected)).not.toThrow(); + }); +}); + describe('assertHeaderType(typ?, allowedTypes?)', () => { it('does not throw error if type is missing and allowed types are not configured', () => { expect(() => assertHeaderType(undefined)).not.toThrow(); diff --git a/packages/backend/src/jwt/assertions.ts b/packages/backend/src/jwt/assertions.ts index b82bdbaf0c3..7ed3d440dcb 100644 --- a/packages/backend/src/jwt/assertions.ts +++ b/packages/backend/src/jwt/assertions.ts @@ -48,15 +48,13 @@ export const assertAudienceClaim = (aud?: unknown, audience?: unknown) => { }; export const assertOAuthAudienceClaim = (aud: unknown, audience?: string | string[]) => { - // if no expected audiences, nothing to check const audienceList = [audience].flat().filter(a => !!a); if (audienceList.length === 0) { return; } - // we are now expecting audiences; - const audFromToken = aud ? [aud as string[] | undefined].flat() : undefined; - if (!audFromToken) { + const audFromToken = aud ? [aud].flat() : undefined; + if (!isArrayString(audFromToken) || audFromToken.some(a => a.length === 0)) { throw new TokenVerificationError({ reason: TokenVerificationErrorReason.TokenVerificationFailed, message: `Invalid OAuth audience claim (aud) ${JSON.stringify(aud)}. Expected a non-empty string or a non-empty array of non-empty strings.`, From 74010921981b7a310e303e8a2fb1b61ef6074ad9 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Mon, 21 Sep 2026 17:30:33 -0400 Subject: [PATCH 10/13] changeset --- .changeset/strict-oauth-audience.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.changeset/strict-oauth-audience.md b/.changeset/strict-oauth-audience.md index fa546081eea..59256c0fe29 100644 --- a/.changeset/strict-oauth-audience.md +++ b/.changeset/strict-oauth-audience.md @@ -2,6 +2,4 @@ '@clerk/backend': major --- -OAuth access token verification now requires a matching `aud` when a non-empty `audience` option is configured, for both opaque tokens and JWTs. Tokens with a missing, empty, malformed, or mismatched audience are rejected. - -Applications that configure `audience` must issue OAuth tokens with a matching audience before upgrading. Session JWT and M2M token verification behavior is unchanged. +When a non-empty `audience` is set, OAuth access tokens must have a valid `aud` with at least one matching value. This check is new for opaque tokens. OAuth JWTs already rejected valid but mismatched audiences; they now also reject missing or malformed `aud` claims. `idPOAuthAccessToken.verify()` now accepts an optional `{ audience }`. Without it, the SDK skips the audience check. Session and M2M verification are unchanged. From 6d522c690238dd814464b5481edb283a5be6e01e Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Mon, 21 Sep 2026 18:08:57 -0400 Subject: [PATCH 11/13] bump changeset down to minor --- .changeset/strict-oauth-audience.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/strict-oauth-audience.md b/.changeset/strict-oauth-audience.md index 59256c0fe29..5c67ad22060 100644 --- a/.changeset/strict-oauth-audience.md +++ b/.changeset/strict-oauth-audience.md @@ -1,5 +1,5 @@ --- -'@clerk/backend': major +'@clerk/backend': minor --- When a non-empty `audience` is set, OAuth access tokens must have a valid `aud` with at least one matching value. This check is new for opaque tokens. OAuth JWTs already rejected valid but mismatched audiences; they now also reject missing or malformed `aud` claims. `idPOAuthAccessToken.verify()` now accepts an optional `{ audience }`. Without it, the SDK skips the audience check. Session and M2M verification are unchanged. From f26735e669ec9aec11558040a504b8298c094162 Mon Sep 17 00:00:00 2001 From: Kevin Wang <26389321+thiskevinwang@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:26:30 -0400 Subject: [PATCH 12/13] update changeset --- .changeset/strict-oauth-audience.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/strict-oauth-audience.md b/.changeset/strict-oauth-audience.md index 5c67ad22060..c9a132c49d0 100644 --- a/.changeset/strict-oauth-audience.md +++ b/.changeset/strict-oauth-audience.md @@ -2,4 +2,5 @@ '@clerk/backend': minor --- -When a non-empty `audience` is set, OAuth access tokens must have a valid `aud` with at least one matching value. This check is new for opaque tokens. OAuth JWTs already rejected valid but mismatched audiences; they now also reject missing or malformed `aud` claims. `idPOAuthAccessToken.verify()` now accepts an optional `{ audience }`. Without it, the SDK skips the audience check. Session and M2M verification are unchanged. +- Fixes an issue where OAuth token validation did not correctly validate audience (`aud`) claims. +- Adds an optional `audience` parameter to `idPOAuthAccessToken.verify()` From d295eb8b245a99c46e10a27ebfe83a92fc16dfa7 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 22 Sep 2026 10:15:48 -0400 Subject: [PATCH 13/13] update changeset --- .changeset/strict-oauth-audience.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/strict-oauth-audience.md b/.changeset/strict-oauth-audience.md index c9a132c49d0..57daea8315c 100644 --- a/.changeset/strict-oauth-audience.md +++ b/.changeset/strict-oauth-audience.md @@ -2,5 +2,5 @@ '@clerk/backend': minor --- -- Fixes an issue where OAuth token validation did not correctly validate audience (`aud`) claims. +- Fixes an issue where OAuth token validation did not correctly validate audience (`aud`) claims. Previous usages that specified `audience` were falsely passing. This upgrade will cause those to start rejecting if the `audience` indeed does not match the OAuth token's `aud` claim, including cases where the `aud` claim is omitted. - Adds an optional `audience` parameter to `idPOAuthAccessToken.verify()`