From 5f57241dc8fc96047238e5f270b1beddafa6c31c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 20:35:07 +0000 Subject: [PATCH 01/11] fix(shared): type JWT aud as string or string array Add the RFC 7519 audience claim to JwtPayload and deprecated ClerkJWTClaims so decodeJwt and verifyJwt expose string | string[] | undefined instead of unknown. Co-authored-by: Kevin Wang --- .changeset/type-jwt-aud-rfc-8707.md | 6 ++ .../src/jwt/__tests__/jwtPayload.type.test.ts | 21 +++++++ .../src/tokens/__tests__/verify.test.ts | 58 ++++++++++++++++++- .../src/types/__tests__/jwtv2.type.test.ts | 45 ++++++++++++++ packages/shared/src/types/jwt.ts | 5 ++ packages/shared/src/types/jwtv2.ts | 5 ++ 6 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 .changeset/type-jwt-aud-rfc-8707.md create mode 100644 packages/backend/src/jwt/__tests__/jwtPayload.type.test.ts create mode 100644 packages/shared/src/types/__tests__/jwtv2.type.test.ts diff --git a/.changeset/type-jwt-aud-rfc-8707.md b/.changeset/type-jwt-aud-rfc-8707.md new file mode 100644 index 00000000000..69cbd6a730a --- /dev/null +++ b/.changeset/type-jwt-aud-rfc-8707.md @@ -0,0 +1,6 @@ +--- +'@clerk/shared': patch +'@clerk/backend': patch +--- + +Type the JWT `aud` claim as an optional `string | string[]` per RFC 7519. Clerk-issued OAuth access tokens may include a single RFC 8707 resource URI as a string. diff --git a/packages/backend/src/jwt/__tests__/jwtPayload.type.test.ts b/packages/backend/src/jwt/__tests__/jwtPayload.type.test.ts new file mode 100644 index 00000000000..d6ad81b8685 --- /dev/null +++ b/packages/backend/src/jwt/__tests__/jwtPayload.type.test.ts @@ -0,0 +1,21 @@ +import { describe, expectTypeOf, it } from 'vitest'; + +import type { JwtPayload } from '@clerk/shared/types'; + +import { decodeJwt, verifyJwt } from '../index'; +import { decodeJwt as decodeJwtResult, verifyJwt as verifyJwtResult } from '../verifyJwt'; + +describe('decodeJwt and verifyJwt audience claim types', () => { + it('exposes the same optional RFC 7519 aud type on JwtPayload', () => { + type PublicDecodeAud = ReturnType['payload']['aud']; + type PublicVerifyAud = Awaited>['aud']; + type ResultDecodeAud = NonNullable['data']>['payload']['aud']; + type ResultVerifyAud = NonNullable>['data']>['aud']; + + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 6a12115c01a..3cf4ea44c81 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -22,7 +22,7 @@ import { JWT_CATEGORY_M2M_TOKEN } from '../jwtCategories'; import { verifyMachineAuthToken, verifyToken } from '../verify'; async function createSignedOAuthJwt( - payload = mockOAuthAccessTokenJwtPayload, + payload: Record = mockOAuthAccessTokenJwtPayload, typ: 'at+jwt' | 'application/at+jwt' | 'JWT' = 'at+jwt', ) { const { data } = await signJwt(payload, signingJwks, { @@ -558,7 +558,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { payload.sub = sub; } - const oauthJwt = await createSignedOAuthJwt(payload as typeof mockOAuthAccessTokenJwtPayload, 'at+jwt'); + const oauthJwt = await createSignedOAuthJwt(payload, 'at+jwt'); const result = await verifyMachineAuthToken(oauthJwt, { apiUrl: 'https://api.clerk.test', @@ -569,6 +569,60 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.tokenType).toBe('oauth_token'); }, ); + + it('verifies OAuth JWT with a matching RFC 8707 resource audience', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const audience = 'https://my-resource.example.com'; + const oauthJwt = await createSignedOAuthJwt({ + ...mockOAuthAccessTokenJwtPayload, + aud: audience, + }); + + const result = await verifyMachineAuthToken(oauthJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + audience, + }); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.data).toBeDefined(); + expect(result.errors).toBeUndefined(); + }); + + it('rejects OAuth JWT with a mismatched RFC 8707 resource audience', async () => { + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const oauthJwt = await createSignedOAuthJwt({ + ...mockOAuthAccessTokenJwtPayload, + aud: 'https://attacker.example.com', + }); + + const result = await verifyMachineAuthToken(oauthJwt, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + audience: 'https://my-resource.example.com', + }); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.data).toBeUndefined(); + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].message).toContain('Invalid JWT audience claim'); + }); }); describe('verifyM2MToken with JWT', () => { diff --git a/packages/shared/src/types/__tests__/jwtv2.type.test.ts b/packages/shared/src/types/__tests__/jwtv2.type.test.ts new file mode 100644 index 00000000000..3f7a34f9d72 --- /dev/null +++ b/packages/shared/src/types/__tests__/jwtv2.type.test.ts @@ -0,0 +1,45 @@ +import { describe, expectTypeOf, it } from 'vitest'; + +import type { ClerkJWTClaims, JWTClaims } from '../jwt'; +import type { Jwt, JwtPayload } from '../jwtv2'; + +describe('JwtPayload aud claim', () => { + it('is an optional RFC 7519 string or string array', () => { + expectTypeOf().toEqualTypeOf(); + }); + + it('allows omitting the claim', () => { + expectTypeOf().toExtend(); + }); + + it('allows a string resource audience', () => { + expectTypeOf().toExtend(); + }); + + it('allows a string-array audience', () => { + expectTypeOf().toExtend(); + }); + + it('is exposed on decodeJwt payload via Jwt', () => { + expectTypeOf().toEqualTypeOf(); + }); +}); + +describe('deprecated ClerkJWTClaims aud claim', () => { + it('stays consistent with JwtPayload', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it('allows omitting the claim', () => { + expectTypeOf().toExtend(); + }); + + it('allows a string resource audience', () => { + expectTypeOf().toExtend(); + }); + + it('allows a string-array audience', () => { + expectTypeOf().toExtend(); + }); +}); diff --git a/packages/shared/src/types/jwt.ts b/packages/shared/src/types/jwt.ts index f2b47790a75..67295ca1946 100644 --- a/packages/shared/src/types/jwt.ts +++ b/packages/shared/src/types/jwt.ts @@ -77,6 +77,11 @@ export interface ClerkJWTClaims { */ azp?: string; + /** + * JWT Audience - [RFC7519#section-4.1.3](https://tools.ietf.org/html/rfc7519#section-4.1.3). + */ + aud?: string | string[]; + /** * JWT Actor - [RFC8693](https://www.rfc-editor.org/rfc/rfc8693.html#name-act-actor-claim). */ diff --git a/packages/shared/src/types/jwtv2.ts b/packages/shared/src/types/jwtv2.ts index da7e1de736a..cb2e288d20a 100644 --- a/packages/shared/src/types/jwtv2.ts +++ b/packages/shared/src/types/jwtv2.ts @@ -82,6 +82,11 @@ type JWTPayloadBase = { */ azp?: string; + /** + * JWT Audience - [RFC7519#section-4.1.3](https://tools.ietf.org/html/rfc7519#section-4.1.3). + */ + aud?: string | string[]; + /** * JWT Actor - [RFC8693](https://www.rfc-editor.org/rfc/rfc8693.html#name-act-actor-claim). */ From 4cef5282499df0bc2f9467a00da95e0d0464ffff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 20:39:03 +0000 Subject: [PATCH 02/11] fix(backend): accept RFC 7519 string aud on M2M JWT payloads Widen the internal M2M JWT audience type so JwtPayload remains assignable after aud is typed as string | string[]. --- packages/backend/src/api/resources/M2MToken.ts | 5 +++-- .../src/api/resources/__tests__/M2MToken.test.ts | 15 +++++++++++++++ .../src/jwt/__tests__/jwtPayload.type.test.ts | 7 +++---- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/api/resources/M2MToken.ts b/packages/backend/src/api/resources/M2MToken.ts index 5b19fdd9603..02e37e93b08 100644 --- a/packages/backend/src/api/resources/M2MToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -7,7 +7,7 @@ type M2MJwtPayload = { exp: number; iat: number; jti?: string; - aud?: string[]; + aud?: string | string[]; scopes?: string; [key: string]: unknown; }; @@ -82,10 +82,11 @@ export class M2MToken { } static fromJwtPayload(payload: M2MJwtPayload, clockSkewInMs = 5000): M2MToken { + const audience = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : []; return new M2MToken( payload.jti ?? '', // jti should always be present in Clerk-issued M2M JWTs payload.sub, - payload.scopes?.split(' ') ?? payload.aud ?? [], + payload.scopes?.split(' ') ?? audience, extractCustomClaims(payload), false, null, diff --git a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts index a0b440430f8..8e4bffd275b 100644 --- a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts +++ b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts @@ -105,6 +105,21 @@ describe('M2MToken', () => { expect(token.scopes).toEqual(['scope1', 'scope2', 'scope3']); }); + it('seeds scopes from a string aud claim', () => { + const payload = { + sub: 'mch_test', + exp: 1666648550, + iat: 1666648250, + jti: 'mt_test', + aud: 'https://my-resource.example.com', + }; + + const token = M2MToken.fromJwtPayload(payload); + + expect(token.scopes).toEqual(['https://my-resource.example.com']); + expect(token.claims).toEqual({ aud: 'https://my-resource.example.com' }); + }); + it('returns empty scopes when neither aud nor scopes present', () => { const payload = { sub: 'mch_test', diff --git a/packages/backend/src/jwt/__tests__/jwtPayload.type.test.ts b/packages/backend/src/jwt/__tests__/jwtPayload.type.test.ts index d6ad81b8685..9cb62ff7ea6 100644 --- a/packages/backend/src/jwt/__tests__/jwtPayload.type.test.ts +++ b/packages/backend/src/jwt/__tests__/jwtPayload.type.test.ts @@ -1,9 +1,8 @@ -import { describe, expectTypeOf, it } from 'vitest'; - import type { JwtPayload } from '@clerk/shared/types'; +import { describe, expectTypeOf, it } from 'vitest'; -import { decodeJwt, verifyJwt } from '../index'; -import { decodeJwt as decodeJwtResult, verifyJwt as verifyJwtResult } from '../verifyJwt'; +import type { decodeJwt, verifyJwt } from '../index'; +import type { decodeJwt as decodeJwtResult, verifyJwt as verifyJwtResult } from '../verifyJwt'; describe('decodeJwt and verifyJwt audience claim types', () => { it('exposes the same optional RFC 7519 aud type on JwtPayload', () => { From 0c52c1b3b30fa7b5e24a1cb30824828ff910c4e2 Mon Sep 17 00:00:00 2001 From: thiskevinwang Date: Wed, 26 Aug 2026 20:45:50 +0000 Subject: [PATCH 03/11] chore(repo): bump @clerk/shared changeset to minor Co-authored-by: Codesmith --- .changeset/type-jwt-aud-rfc-8707.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/type-jwt-aud-rfc-8707.md b/.changeset/type-jwt-aud-rfc-8707.md index 69cbd6a730a..d132f87aaea 100644 --- a/.changeset/type-jwt-aud-rfc-8707.md +++ b/.changeset/type-jwt-aud-rfc-8707.md @@ -1,5 +1,5 @@ --- -'@clerk/shared': patch +'@clerk/shared': minor '@clerk/backend': patch --- From 0f9e70673e18a6ff4e77ac3cdc32086234d81e7e Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 26 Aug 2026 17:08:18 -0400 Subject: [PATCH 04/11] trim extra tests --- .../src/jwt/__tests__/jwtPayload.type.test.ts | 20 --------- .../src/types/__tests__/jwtv2.type.test.ts | 45 ------------------- 2 files changed, 65 deletions(-) delete mode 100644 packages/backend/src/jwt/__tests__/jwtPayload.type.test.ts delete mode 100644 packages/shared/src/types/__tests__/jwtv2.type.test.ts diff --git a/packages/backend/src/jwt/__tests__/jwtPayload.type.test.ts b/packages/backend/src/jwt/__tests__/jwtPayload.type.test.ts deleted file mode 100644 index 9cb62ff7ea6..00000000000 --- a/packages/backend/src/jwt/__tests__/jwtPayload.type.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { JwtPayload } from '@clerk/shared/types'; -import { describe, expectTypeOf, it } from 'vitest'; - -import type { decodeJwt, verifyJwt } from '../index'; -import type { decodeJwt as decodeJwtResult, verifyJwt as verifyJwtResult } from '../verifyJwt'; - -describe('decodeJwt and verifyJwt audience claim types', () => { - it('exposes the same optional RFC 7519 aud type on JwtPayload', () => { - type PublicDecodeAud = ReturnType['payload']['aud']; - type PublicVerifyAud = Awaited>['aud']; - type ResultDecodeAud = NonNullable['data']>['payload']['aud']; - type ResultVerifyAud = NonNullable>['data']>['aud']; - - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - }); -}); diff --git a/packages/shared/src/types/__tests__/jwtv2.type.test.ts b/packages/shared/src/types/__tests__/jwtv2.type.test.ts deleted file mode 100644 index 3f7a34f9d72..00000000000 --- a/packages/shared/src/types/__tests__/jwtv2.type.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expectTypeOf, it } from 'vitest'; - -import type { ClerkJWTClaims, JWTClaims } from '../jwt'; -import type { Jwt, JwtPayload } from '../jwtv2'; - -describe('JwtPayload aud claim', () => { - it('is an optional RFC 7519 string or string array', () => { - expectTypeOf().toEqualTypeOf(); - }); - - it('allows omitting the claim', () => { - expectTypeOf().toExtend(); - }); - - it('allows a string resource audience', () => { - expectTypeOf().toExtend(); - }); - - it('allows a string-array audience', () => { - expectTypeOf().toExtend(); - }); - - it('is exposed on decodeJwt payload via Jwt', () => { - expectTypeOf().toEqualTypeOf(); - }); -}); - -describe('deprecated ClerkJWTClaims aud claim', () => { - it('stays consistent with JwtPayload', () => { - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - }); - - it('allows omitting the claim', () => { - expectTypeOf().toExtend(); - }); - - it('allows a string resource audience', () => { - expectTypeOf().toExtend(); - }); - - it('allows a string-array audience', () => { - expectTypeOf().toExtend(); - }); -}); From 0769cad8b66eff9c218689c3ad51654037e94c5f Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 26 Aug 2026 17:13:56 -0400 Subject: [PATCH 05/11] make logic more human friendly --- packages/backend/src/api/resources/M2MToken.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/api/resources/M2MToken.ts b/packages/backend/src/api/resources/M2MToken.ts index 02e37e93b08..2132ffc5f33 100644 --- a/packages/backend/src/api/resources/M2MToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -82,7 +82,15 @@ export class M2MToken { } static fromJwtPayload(payload: M2MJwtPayload, clockSkewInMs = 5000): M2MToken { - const audience = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : []; + let audience: string[] = []; + // If audience is an array, use it directly; + // If it's a string, wrap it in an array; + // If it's undefined, leave it as an empty array. + if (Array.isArray(payload.aud)) { + audience = payload.aud; + } else if (payload.aud) { + audience = [payload.aud]; + } return new M2MToken( payload.jti ?? '', // jti should always be present in Clerk-issued M2M JWTs payload.sub, From 91cdb88f0c77e4e9d1e7c2a4c9f0b3bdbe8fa348 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 26 Aug 2026 17:19:09 -0400 Subject: [PATCH 06/11] toMatchInlineSnapshot --- .../src/tokens/__tests__/verify.test.ts | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 3cf4ea44c81..03670182711 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -593,7 +593,24 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { }); expect(result.tokenType).toBe('oauth_token'); - expect(result.data).toBeDefined(); + expect(result.data).toMatchInlineSnapshot(` + IdPOAuthAccessToken { + "clientId": "client_2VTWUzvGC5UhdJCNx6xG1D98edc", + "createdAt": 1666648250000, + "expiration": 1666648550000, + "expired": false, + "id": "oat_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE", + "revocationReason": null, + "revoked": false, + "scopes": [ + "read:foo", + "write:bar", + ], + "subject": "user_2vYVtestTESTtestTESTtestTESTtest", + "type": "oauth_token", + "updatedAt": 1666648250000, + } + `); expect(result.errors).toBeUndefined(); }); @@ -620,8 +637,10 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.tokenType).toBe('oauth_token'); expect(result.data).toBeUndefined(); - expect(result.errors).toBeDefined(); - expect(result.errors?.[0].message).toContain('Invalid JWT audience claim'); + 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"]".]`, + ); }); }); From b40da6d1435532f3acd17d31a7c9efb90ecf6d1f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 21:33:17 +0000 Subject: [PATCH 07/11] chore(ci): retrigger unit tests after unrelated checkout timeout From 0d3bfad73d92cbbe2cbd7a9abbe95163abcb5009 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 26 Aug 2026 17:44:49 -0400 Subject: [PATCH 08/11] Ensure aud is in IdPOAuthAccessToken --- .changeset/type-jwt-aud-rfc-8707.md | 4 ++-- .../backend/src/api/resources/IdPOAuthAccessToken.ts | 11 +++++++---- packages/backend/src/tokens/__tests__/verify.test.ts | 2 ++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.changeset/type-jwt-aud-rfc-8707.md b/.changeset/type-jwt-aud-rfc-8707.md index d132f87aaea..e9db961804b 100644 --- a/.changeset/type-jwt-aud-rfc-8707.md +++ b/.changeset/type-jwt-aud-rfc-8707.md @@ -1,6 +1,6 @@ --- '@clerk/shared': minor -'@clerk/backend': patch +'@clerk/backend': minor --- -Type the JWT `aud` claim as an optional `string | string[]` per RFC 7519. Clerk-issued OAuth access tokens may include a single RFC 8707 resource URI as a string. +Type the JWT `aud` claim as an optional `string | string[]` per RFC 7519 and expose it on verified OAuth JWT access tokens. Clerk-issued OAuth access tokens may include a single RFC 8707 resource URI as a string. diff --git a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts index 8399057e59b..286e6c2bfaf 100644 --- a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts +++ b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts @@ -25,6 +25,8 @@ export class IdPOAuthAccessToken { readonly createdAt: number, /** The Unix timestamp (in milliseconds) when the access token was last updated. */ readonly updatedAt: number, + /** The intended audience for the access token. */ + readonly aud?: string | string[], ) {} static fromJSON(data: IdPOAuthAccessTokenJSON) { @@ -59,10 +61,11 @@ export class IdPOAuthAccessToken { oauthPayload.scp ?? oauthPayload.scope?.split(' ') ?? [], false, null, - payload.exp * 1000 <= Date.now() - clockSkewInMs, - payload.exp * 1000, // milliseconds: expiration, converted from JWT exp claim - payload.iat * 1000, // milliseconds: createdAt, converted from JWT iat claim - payload.iat * 1000, // milliseconds: updatedAt, no JWT equivalent, defaults to iat + oauthPayload.exp * 1000 <= Date.now() - clockSkewInMs, + oauthPayload.exp * 1000, // milliseconds: expiration, converted from JWT exp claim + oauthPayload.iat * 1000, // milliseconds: createdAt, converted from JWT iat claim + oauthPayload.iat * 1000, // milliseconds: updatedAt, no JWT equivalent, defaults to iat + oauthPayload.aud, ); } } diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 03670182711..603b858a090 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -595,6 +595,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(result.tokenType).toBe('oauth_token'); expect(result.data).toMatchInlineSnapshot(` IdPOAuthAccessToken { + "aud": "https://my-resource.example.com", "clientId": "client_2VTWUzvGC5UhdJCNx6xG1D98edc", "createdAt": 1666648250000, "expiration": 1666648550000, @@ -611,6 +612,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { "updatedAt": 1666648250000, } `); + expect((result.data as IdPOAuthAccessToken).aud).toBe(audience); expect(result.errors).toBeUndefined(); }); From 8c62389e12f2754b5dfcd0bcbd3796315de92a2c Mon Sep 17 00:00:00 2001 From: thiskevinwang Date: Wed, 26 Aug 2026 21:50:28 +0000 Subject: [PATCH 09/11] fix(backend): preserve aud when hydrating IdPOAuthAccessToken from JSON Co-authored-by: Codesmith --- packages/backend/src/api/resources/IdPOAuthAccessToken.ts | 1 + packages/backend/src/api/resources/JSON.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts index 286e6c2bfaf..71cc9361b4c 100644 --- a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts +++ b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts @@ -42,6 +42,7 @@ export class IdPOAuthAccessToken { data.expiration, data.created_at, data.updated_at, + data.aud, ); } diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index e1ff98e2ee1..69ee1e82b4d 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -962,6 +962,7 @@ export interface IdPOAuthAccessTokenJSON extends ClerkResourceJSON { expiration: number | null; created_at: number; updated_at: number; + aud?: string | string[]; } export interface BillingPayerJSON extends ClerkResourceJSON { From f4f22e4c7904b7aa29445da5927d3e5867cbef61 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Fri, 11 Sep 2026 12:58:05 -0400 Subject: [PATCH 10/11] remove m2m changes --- .changeset/type-jwt-aud-rfc-8707.md | 3 +- .../src/api/resources/IdPOAuthAccessToken.ts | 9 +++-- .../backend/src/api/resources/M2MToken.ts | 13 +------ .../api/resources/__tests__/M2MToken.test.ts | 15 -------- .../src/tokens/__tests__/verify.test.ts | 38 +++++++------------ packages/shared/src/types/jwt.ts | 5 --- packages/shared/src/types/jwtv2.ts | 5 --- 7 files changed, 22 insertions(+), 66 deletions(-) diff --git a/.changeset/type-jwt-aud-rfc-8707.md b/.changeset/type-jwt-aud-rfc-8707.md index e9db961804b..0ea9753fa74 100644 --- a/.changeset/type-jwt-aud-rfc-8707.md +++ b/.changeset/type-jwt-aud-rfc-8707.md @@ -1,6 +1,5 @@ --- -'@clerk/shared': minor '@clerk/backend': minor --- -Type the JWT `aud` claim as an optional `string | string[]` per RFC 7519 and expose it on verified OAuth JWT access tokens. Clerk-issued OAuth access tokens may include a single RFC 8707 resource URI as a string. +Expose the optional `aud` audience as `string | string[]` on verified OAuth access tokens, for both JWT and opaque tokens. diff --git a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts index 71cc9361b4c..0dff41b2b3d 100644 --- a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts +++ b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts @@ -3,6 +3,7 @@ import type { JwtPayload } from '@clerk/shared/types'; import type { IdPOAuthAccessTokenJSON } from './JSON'; type OAuthJwtPayload = JwtPayload & { + aud?: string | string[]; jti?: string; client_id?: string; scope?: string; @@ -62,10 +63,10 @@ export class IdPOAuthAccessToken { oauthPayload.scp ?? oauthPayload.scope?.split(' ') ?? [], false, null, - oauthPayload.exp * 1000 <= Date.now() - clockSkewInMs, - oauthPayload.exp * 1000, // milliseconds: expiration, converted from JWT exp claim - oauthPayload.iat * 1000, // milliseconds: createdAt, converted from JWT iat claim - oauthPayload.iat * 1000, // milliseconds: updatedAt, no JWT equivalent, defaults to iat + payload.exp * 1000 <= Date.now() - clockSkewInMs, + payload.exp * 1000, // milliseconds: expiration, converted from JWT exp claim + payload.iat * 1000, // milliseconds: createdAt, converted from JWT iat claim + payload.iat * 1000, // milliseconds: updatedAt, no JWT equivalent, defaults to iat oauthPayload.aud, ); } diff --git a/packages/backend/src/api/resources/M2MToken.ts b/packages/backend/src/api/resources/M2MToken.ts index 2132ffc5f33..5b19fdd9603 100644 --- a/packages/backend/src/api/resources/M2MToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -7,7 +7,7 @@ type M2MJwtPayload = { exp: number; iat: number; jti?: string; - aud?: string | string[]; + aud?: string[]; scopes?: string; [key: string]: unknown; }; @@ -82,19 +82,10 @@ export class M2MToken { } static fromJwtPayload(payload: M2MJwtPayload, clockSkewInMs = 5000): M2MToken { - let audience: string[] = []; - // If audience is an array, use it directly; - // If it's a string, wrap it in an array; - // If it's undefined, leave it as an empty array. - if (Array.isArray(payload.aud)) { - audience = payload.aud; - } else if (payload.aud) { - audience = [payload.aud]; - } return new M2MToken( payload.jti ?? '', // jti should always be present in Clerk-issued M2M JWTs payload.sub, - payload.scopes?.split(' ') ?? audience, + payload.scopes?.split(' ') ?? payload.aud ?? [], extractCustomClaims(payload), false, null, diff --git a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts index 8e4bffd275b..a0b440430f8 100644 --- a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts +++ b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts @@ -105,21 +105,6 @@ describe('M2MToken', () => { expect(token.scopes).toEqual(['scope1', 'scope2', 'scope3']); }); - it('seeds scopes from a string aud claim', () => { - const payload = { - sub: 'mch_test', - exp: 1666648550, - iat: 1666648250, - jti: 'mt_test', - aud: 'https://my-resource.example.com', - }; - - const token = M2MToken.fromJwtPayload(payload); - - expect(token.scopes).toEqual(['https://my-resource.example.com']); - expect(token.claims).toEqual({ aud: 'https://my-resource.example.com' }); - }); - it('returns empty scopes when neither aud nor scopes present', () => { const payload = { sub: 'mch_test', diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 603b858a090..a16944e5947 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -230,14 +230,18 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(data.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); }); - it('verifies provided OAuth token', async () => { + it.each([ + { aud: undefined }, + { aud: 'https://my-resource.example.com' }, + { aud: ['https://my-resource.example.com', 'https://other-resource.example.com'] }, + ])('verifies opaque OAuth token with aud=$aud', async ({ aud }) => { const token = 'oat_8XOIucKvqHVr5tYP123456789abcdefghij'; server.use( http.post( 'https://api.clerk.test/oauth_applications/access_tokens/verify', validateHeaders(() => { - return HttpResponse.json(mockVerificationResults.oauth_token); + return HttpResponse.json({ ...mockVerificationResults.oauth_token, ...(aud === undefined ? {} : { aud }) }); }), ), ); @@ -255,6 +259,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(data.id).toBe('oat_2VTWUzvGC5UhdJCNx6xG1D98edc'); expect(data.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); expect(data.scopes).toEqual(['read:foo', 'write:bar']); + expect(data.aud).toEqual(aud); }); describe('handles API errors for API keys', () => { @@ -424,6 +429,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { expect(data.type).toBe('oauth_token'); expect(data.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); expect(data.scopes).toEqual(['read:foo', 'write:bar']); + expect(data.aud).toBeUndefined(); // Timestamps are exposed in milliseconds, matching M2MToken and the API JSON shape expect(data.expiration).toBe(mockOAuthAccessTokenJwtPayload.exp * 1000); expect(data.createdAt).toBe(mockOAuthAccessTokenJwtPayload.iat * 1000); @@ -570,7 +576,10 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { }, ); - it('verifies OAuth JWT with a matching RFC 8707 resource audience', async () => { + it.each([ + { aud: 'https://my-resource.example.com' }, + { aud: ['https://my-resource.example.com', 'https://other-resource.example.com'] }, + ])('verifies OAuth JWT with a matching resource audience aud=$aud', async ({ aud }) => { server.use( http.get( 'https://api.clerk.test/v1/jwks', @@ -583,7 +592,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { const audience = 'https://my-resource.example.com'; const oauthJwt = await createSignedOAuthJwt({ ...mockOAuthAccessTokenJwtPayload, - aud: audience, + aud, }); const result = await verifyMachineAuthToken(oauthJwt, { @@ -593,26 +602,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { }); expect(result.tokenType).toBe('oauth_token'); - expect(result.data).toMatchInlineSnapshot(` - IdPOAuthAccessToken { - "aud": "https://my-resource.example.com", - "clientId": "client_2VTWUzvGC5UhdJCNx6xG1D98edc", - "createdAt": 1666648250000, - "expiration": 1666648550000, - "expired": false, - "id": "oat_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE", - "revocationReason": null, - "revoked": false, - "scopes": [ - "read:foo", - "write:bar", - ], - "subject": "user_2vYVtestTESTtestTESTtestTESTtest", - "type": "oauth_token", - "updatedAt": 1666648250000, - } - `); - expect((result.data as IdPOAuthAccessToken).aud).toBe(audience); + expect(result.data).toMatchObject({ aud, scopes: ['read:foo', 'write:bar'] }); expect(result.errors).toBeUndefined(); }); diff --git a/packages/shared/src/types/jwt.ts b/packages/shared/src/types/jwt.ts index 67295ca1946..f2b47790a75 100644 --- a/packages/shared/src/types/jwt.ts +++ b/packages/shared/src/types/jwt.ts @@ -77,11 +77,6 @@ export interface ClerkJWTClaims { */ azp?: string; - /** - * JWT Audience - [RFC7519#section-4.1.3](https://tools.ietf.org/html/rfc7519#section-4.1.3). - */ - aud?: string | string[]; - /** * JWT Actor - [RFC8693](https://www.rfc-editor.org/rfc/rfc8693.html#name-act-actor-claim). */ diff --git a/packages/shared/src/types/jwtv2.ts b/packages/shared/src/types/jwtv2.ts index cb2e288d20a..da7e1de736a 100644 --- a/packages/shared/src/types/jwtv2.ts +++ b/packages/shared/src/types/jwtv2.ts @@ -82,11 +82,6 @@ type JWTPayloadBase = { */ azp?: string; - /** - * JWT Audience - [RFC7519#section-4.1.3](https://tools.ietf.org/html/rfc7519#section-4.1.3). - */ - aud?: string | string[]; - /** * JWT Actor - [RFC8693](https://www.rfc-editor.org/rfc/rfc8693.html#name-act-actor-claim). */ From e737f3cc88ef6b033306b5bf3f978e2a12781057 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Fri, 11 Sep 2026 13:16:56 -0400 Subject: [PATCH 11/11] fix types based on backend contract --- .changeset/type-jwt-aud-rfc-8707.md | 2 +- packages/backend/src/api/resources/JSON.ts | 2 +- packages/backend/src/tokens/__tests__/verify.test.ts | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.changeset/type-jwt-aud-rfc-8707.md b/.changeset/type-jwt-aud-rfc-8707.md index 0ea9753fa74..50b1fcc4dd3 100644 --- a/.changeset/type-jwt-aud-rfc-8707.md +++ b/.changeset/type-jwt-aud-rfc-8707.md @@ -2,4 +2,4 @@ '@clerk/backend': minor --- -Expose the optional `aud` audience as `string | string[]` on verified OAuth access tokens, for both JWT and opaque tokens. +Expose the optional `aud` audience on verified OAuth access tokens. \ No newline at end of file diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index 69ee1e82b4d..b731aa2cfb8 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -962,7 +962,7 @@ export interface IdPOAuthAccessTokenJSON extends ClerkResourceJSON { expiration: number | null; created_at: number; updated_at: number; - aud?: string | string[]; + aud?: string[]; } export interface BillingPayerJSON extends ClerkResourceJSON { diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index a16944e5947..01784d3200e 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -232,7 +232,8 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { it.each([ { aud: undefined }, - { aud: 'https://my-resource.example.com' }, + { aud: [] }, + { aud: ['https://my-resource.example.com'] }, { aud: ['https://my-resource.example.com', 'https://other-resource.example.com'] }, ])('verifies opaque OAuth token with aud=$aud', async ({ aud }) => { const token = 'oat_8XOIucKvqHVr5tYP123456789abcdefghij';