From 2970146ef95fe3342b22655824dc967b18e4c210 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 28 May 2026 14:59:35 -0500 Subject: [PATCH 1/2] fix(backend): preserve custom claims when verifying JWT M2M tokens --- .changeset/m2m-jwt-custom-claims.md | 5 ++++ .../src/api/__tests__/M2MTokenApi.test.ts | 26 ++++++++++++++++- .../backend/src/api/resources/M2MToken.ts | 23 ++++++++++++++- .../api/resources/__tests__/M2MToken.test.ts | 29 +++++++++++++++++++ 4 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 .changeset/m2m-jwt-custom-claims.md diff --git a/.changeset/m2m-jwt-custom-claims.md b/.changeset/m2m-jwt-custom-claims.md new file mode 100644 index 00000000000..43a2f75df3e --- /dev/null +++ b/.changeset/m2m-jwt-custom-claims.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Preserve custom claims when verifying JWT-format M2M tokens. `M2MToken.fromJwtPayload` previously hardcoded `claims` to `null`, so `client.m2m.verify()` (and request-level `auth()`) dropped any custom claims embedded in the token. Custom claims are now reconstructed from the verified payload by excluding the reserved JWT/M2M structural claims (`iss`, `sub`, `aud`, `exp`, `nbf`, `iat`, `jti`, `scopes`). Tokens without custom claims still return `claims: null`, consistent with the opaque-token path. diff --git a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts index 463366dbe37..6a90327eebe 100644 --- a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts +++ b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts @@ -417,7 +417,7 @@ describe('M2MToken', () => { }); }); - async function createSignedM2MJwt(payload = mockM2MJwtPayload) { + async function createSignedM2MJwt(payload: Record = mockM2MJwtPayload) { const { data } = await signJwt(payload, signingJwks, { algorithm: 'RS256', header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, @@ -455,6 +455,30 @@ describe('M2MToken', () => { expect(result.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); }); + it('preserves custom claims embedded in a JWT M2M token', async () => { + const m2mApi = new M2MTokenApi( + buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }), + { secretKey: 'sk_test_xxxxx', apiUrl: 'https://api.clerk.test', skipJwksCache: true }, + ); + + server.use( + http.get( + 'https://api.clerk.test/v1/jwks', + validateHeaders(() => HttpResponse.json(mockJwks)), + ), + ); + + const jwtToken = await createSignedM2MJwt({ + ...mockM2MJwtPayload, + permissions: ['read:users', 'read:orders'], + role: 'service', + }); + const result = await m2mApi.verify({ token: jwtToken }); + + expect(result.claims).toEqual({ permissions: ['read:users', 'read:orders'], role: 'service' }); + expect(result.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); + }); + it('throws when JWT signature cannot be verified', async () => { const m2mApi = new M2MTokenApi( buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }), diff --git a/packages/backend/src/api/resources/M2MToken.ts b/packages/backend/src/api/resources/M2MToken.ts index e3253329056..bf56779f6e3 100644 --- a/packages/backend/src/api/resources/M2MToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -12,6 +12,27 @@ type M2MJwtPayload = { [key: string]: unknown; }; +// Registered JWT claims plus the Clerk-specific structural claims that +// `fromJwtPayload` maps onto dedicated `M2MToken` fields. Everything else in +// the payload is a user-defined custom claim and is surfaced through `claims`. +const M2M_RESERVED_JWT_CLAIMS = new Set(['iss', 'sub', 'aud', 'exp', 'nbf', 'iat', 'jti', 'scopes']); + +/** + * Reconstructs the custom claims that were attached at token creation by + * stripping the reserved JWT/M2M claims from the verified payload. Returns + * `null` when no custom claims are present, matching the opaque-token path + * where a token created without claims verifies back to `claims: null`. + */ +function extractCustomClaims(payload: M2MJwtPayload): Record | null { + const claims: Record = {}; + for (const key of Object.keys(payload)) { + if (!M2M_RESERVED_JWT_CLAIMS.has(key)) { + claims[key] = payload[key]; + } + } + return Object.keys(claims).length > 0 ? claims : null; +} + /** * The Backend `M2MToken` object holds information about a machine-to-machine token. */ @@ -51,7 +72,7 @@ export class M2MToken { payload.jti ?? '', // jti should always be present in Clerk-issued M2M JWTs payload.sub, payload.scopes?.split(' ') ?? payload.aud ?? [], - null, + extractCustomClaims(payload), false, null, payload.exp * 1000 <= Date.now() - clockSkewInMs, diff --git a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts index ca158ae2e37..5d04fba180e 100644 --- a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts +++ b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts @@ -38,6 +38,35 @@ describe('M2MToken', () => { expect(token.updatedAt).toBe(1666648250 * 1000); }); + it('preserves custom claims and excludes reserved JWT claims', () => { + const payload = { + iss: 'https://clerk.m2m.example.test', + sub: 'mch_2vYVtestTESTtestTESTtestTESTtest', + aud: ['mch_1xxxxx'], + exp: 1666648550, + iat: 1666648250, + nbf: 1666648240, + jti: 'mt_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE', + scopes: 'scope1 scope2', + permissions: ['read:users', 'read:orders'], + role: 'service', + }; + + const token = M2MToken.fromJwtPayload(payload); + + expect(token.claims).toEqual({ + permissions: ['read:users', 'read:orders'], + role: 'service', + }); + // Reserved claims are mapped to dedicated fields, not leaked into `claims`. + expect(token.claims).not.toHaveProperty('iss'); + expect(token.claims).not.toHaveProperty('sub'); + expect(token.claims).not.toHaveProperty('aud'); + expect(token.claims).not.toHaveProperty('scopes'); + expect(token.claims).not.toHaveProperty('jti'); + expect(token.scopes).toEqual(['scope1', 'scope2']); + }); + it('prefers scopes claim over aud when both are present', () => { const payload = { sub: 'mch_test', From 95eb4a9e9fb3d6907d3310553eb7164e2ecb2a1e Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 28 May 2026 21:10:40 -0500 Subject: [PATCH 2/2] update claims handling --- .changeset/m2m-jwt-custom-claims.md | 2 +- .../src/api/__tests__/M2MTokenApi.test.ts | 9 ++++++++- .../backend/src/api/resources/M2MToken.ts | 17 ++++++++++------- .../api/resources/__tests__/M2MToken.test.ts | 19 ++++++++++++++----- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/.changeset/m2m-jwt-custom-claims.md b/.changeset/m2m-jwt-custom-claims.md index 43a2f75df3e..ef38bd26bde 100644 --- a/.changeset/m2m-jwt-custom-claims.md +++ b/.changeset/m2m-jwt-custom-claims.md @@ -2,4 +2,4 @@ '@clerk/backend': patch --- -Preserve custom claims when verifying JWT-format M2M tokens. `M2MToken.fromJwtPayload` previously hardcoded `claims` to `null`, so `client.m2m.verify()` (and request-level `auth()`) dropped any custom claims embedded in the token. Custom claims are now reconstructed from the verified payload by excluding the reserved JWT/M2M structural claims (`iss`, `sub`, `aud`, `exp`, `nbf`, `iat`, `jti`, `scopes`). Tokens without custom claims still return `claims: null`, consistent with the opaque-token path. +Preserve custom claims when verifying JWT-format M2M tokens. `M2MToken.fromJwtPayload` previously hardcoded `claims` to `null`, so `client.m2m.verify()` (and request-level `auth()`) dropped any custom claims embedded in the token. Custom claims are now reconstructed from the verified payload by stripping only the structural claims the backend adds when minting the token (`iss`, `sub`, `exp`, `nbf`, `iat`, `jti`). User-supplied claims such as `aud` are preserved. Tokens without custom claims still return `claims: null`, consistent with the opaque-token path. diff --git a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts index 6a90327eebe..82ccbb6def5 100644 --- a/packages/backend/src/api/__tests__/M2MTokenApi.test.ts +++ b/packages/backend/src/api/__tests__/M2MTokenApi.test.ts @@ -475,7 +475,14 @@ describe('M2MToken', () => { }); const result = await m2mApi.verify({ token: jwtToken }); - expect(result.claims).toEqual({ permissions: ['read:users', 'read:orders'], role: 'service' }); + // `aud` and `scopes` from the token are user-supplied custom claims and are + // preserved in `claims`; `scopes` additionally seeds the dedicated field. + expect(result.claims).toEqual({ + aud: ['mch_1xxxxx', 'mch_2xxxxx'], + scopes: 'mch_1xxxxx mch_2xxxxx', + permissions: ['read:users', 'read:orders'], + role: 'service', + }); expect(result.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); }); diff --git a/packages/backend/src/api/resources/M2MToken.ts b/packages/backend/src/api/resources/M2MToken.ts index bf56779f6e3..a9ad2b1d2e4 100644 --- a/packages/backend/src/api/resources/M2MToken.ts +++ b/packages/backend/src/api/resources/M2MToken.ts @@ -12,16 +12,19 @@ type M2MJwtPayload = { [key: string]: unknown; }; -// Registered JWT claims plus the Clerk-specific structural claims that -// `fromJwtPayload` maps onto dedicated `M2MToken` fields. Everything else in -// the payload is a user-defined custom claim and is surfaced through `claims`. -const M2M_RESERVED_JWT_CLAIMS = new Set(['iss', 'sub', 'aud', 'exp', 'nbf', 'iat', 'jti', 'scopes']); +// Structural claims that Clerk's machine-token service always adds when it mints +// an M2M JWT. These are mapped onto dedicated `M2MToken` fields, so they are +// stripped from `claims`. Everything else is a user-supplied custom claim and is +// surfaced through `claims`, including `aud` and `scopes`, which the backend +// treats as custom claims (they are neither reserved nor auto-added). +const M2M_RESERVED_JWT_CLAIMS = new Set(['iss', 'sub', 'exp', 'nbf', 'iat', 'jti']); /** * Reconstructs the custom claims that were attached at token creation by - * stripping the reserved JWT/M2M claims from the verified payload. Returns - * `null` when no custom claims are present, matching the opaque-token path - * where a token created without claims verifies back to `claims: null`. + * stripping the structural claims (see `M2M_RESERVED_JWT_CLAIMS`) from the + * verified payload. Returns `null` when no custom claims are present, matching + * the opaque-token path where a token created without claims verifies back to + * `claims: null`. */ function extractCustomClaims(payload: M2MJwtPayload): Record | null { const claims: Record = {}; diff --git a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts index 5d04fba180e..a0b440430f8 100644 --- a/packages/backend/src/api/resources/__tests__/M2MToken.test.ts +++ b/packages/backend/src/api/resources/__tests__/M2MToken.test.ts @@ -29,7 +29,9 @@ describe('M2MToken', () => { expect(token.id).toBe('mt_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE'); expect(token.subject).toBe('mch_2vYVtestTESTtestTESTtestTESTtest'); expect(token.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']); - expect(token.claims).toBeNull(); + // `aud` is a user-supplied custom claim (the backend does not auto-add it), + // so it is surfaced through `claims` while also seeding the `scopes` field. + expect(token.claims).toEqual({ aud: ['mch_1xxxxx', 'mch_2xxxxx'] }); expect(token.revoked).toBe(false); expect(token.revocationReason).toBeNull(); expect(token.expired).toBe(false); @@ -38,7 +40,7 @@ describe('M2MToken', () => { expect(token.updatedAt).toBe(1666648250 * 1000); }); - it('preserves custom claims and excludes reserved JWT claims', () => { + it('preserves custom claims (including aud and scopes) and strips only structural claims', () => { const payload = { iss: 'https://clerk.m2m.example.test', sub: 'mch_2vYVtestTESTtestTESTtestTESTtest', @@ -54,16 +56,23 @@ describe('M2MToken', () => { const token = M2MToken.fromJwtPayload(payload); + // `aud` and `scopes` are user-supplied custom claims in Clerk-issued M2M + // tokens (the backend neither reserves nor auto-adds them), so they are + // preserved in `claims` alongside any other custom claims. expect(token.claims).toEqual({ + aud: ['mch_1xxxxx'], + scopes: 'scope1 scope2', permissions: ['read:users', 'read:orders'], role: 'service', }); - // Reserved claims are mapped to dedicated fields, not leaked into `claims`. + // Structural claims are mapped to dedicated fields, not leaked into `claims`. expect(token.claims).not.toHaveProperty('iss'); expect(token.claims).not.toHaveProperty('sub'); - expect(token.claims).not.toHaveProperty('aud'); - expect(token.claims).not.toHaveProperty('scopes'); + expect(token.claims).not.toHaveProperty('exp'); + expect(token.claims).not.toHaveProperty('nbf'); + expect(token.claims).not.toHaveProperty('iat'); expect(token.claims).not.toHaveProperty('jti'); + // `scopes` is still derived onto the dedicated `scopes` field. expect(token.scopes).toEqual(['scope1', 'scope2']); });