Skip to content

Commit be55c4e

Browse files
authored
fix(backend): preserve custom claims when verifying JWT M2M tokens (#8697)
1 parent 846a145 commit be55c4e

4 files changed

Lines changed: 101 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': patch
3+
---
4+
5+
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.

packages/backend/src/api/__tests__/M2MTokenApi.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ describe('M2MToken', () => {
417417
});
418418
});
419419

420-
async function createSignedM2MJwt(payload = mockM2MJwtPayload) {
420+
async function createSignedM2MJwt(payload: Record<string, unknown> = mockM2MJwtPayload) {
421421
const { data } = await signJwt(payload, signingJwks, {
422422
algorithm: 'RS256',
423423
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
@@ -455,6 +455,37 @@ describe('M2MToken', () => {
455455
expect(result.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
456456
});
457457

458+
it('preserves custom claims embedded in a JWT M2M token', async () => {
459+
const m2mApi = new M2MTokenApi(
460+
buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }),
461+
{ secretKey: 'sk_test_xxxxx', apiUrl: 'https://api.clerk.test', skipJwksCache: true },
462+
);
463+
464+
server.use(
465+
http.get(
466+
'https://api.clerk.test/v1/jwks',
467+
validateHeaders(() => HttpResponse.json(mockJwks)),
468+
),
469+
);
470+
471+
const jwtToken = await createSignedM2MJwt({
472+
...mockM2MJwtPayload,
473+
permissions: ['read:users', 'read:orders'],
474+
role: 'service',
475+
});
476+
const result = await m2mApi.verify({ token: jwtToken });
477+
478+
// `aud` and `scopes` from the token are user-supplied custom claims and are
479+
// preserved in `claims`; `scopes` additionally seeds the dedicated field.
480+
expect(result.claims).toEqual({
481+
aud: ['mch_1xxxxx', 'mch_2xxxxx'],
482+
scopes: 'mch_1xxxxx mch_2xxxxx',
483+
permissions: ['read:users', 'read:orders'],
484+
role: 'service',
485+
});
486+
expect(result.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
487+
});
488+
458489
it('throws when JWT signature cannot be verified', async () => {
459490
const m2mApi = new M2MTokenApi(
460491
buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }),

packages/backend/src/api/resources/M2MToken.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,30 @@ type M2MJwtPayload = {
1212
[key: string]: unknown;
1313
};
1414

15+
// Structural claims that Clerk's machine-token service always adds when it mints
16+
// an M2M JWT. These are mapped onto dedicated `M2MToken` fields, so they are
17+
// stripped from `claims`. Everything else is a user-supplied custom claim and is
18+
// surfaced through `claims`, including `aud` and `scopes`, which the backend
19+
// treats as custom claims (they are neither reserved nor auto-added).
20+
const M2M_RESERVED_JWT_CLAIMS = new Set(['iss', 'sub', 'exp', 'nbf', 'iat', 'jti']);
21+
22+
/**
23+
* Reconstructs the custom claims that were attached at token creation by
24+
* stripping the structural claims (see `M2M_RESERVED_JWT_CLAIMS`) from the
25+
* verified payload. Returns `null` when no custom claims are present, matching
26+
* the opaque-token path where a token created without claims verifies back to
27+
* `claims: null`.
28+
*/
29+
function extractCustomClaims(payload: M2MJwtPayload): Record<string, any> | null {
30+
const claims: Record<string, any> = {};
31+
for (const key of Object.keys(payload)) {
32+
if (!M2M_RESERVED_JWT_CLAIMS.has(key)) {
33+
claims[key] = payload[key];
34+
}
35+
}
36+
return Object.keys(claims).length > 0 ? claims : null;
37+
}
38+
1539
/**
1640
* The Backend `M2MToken` object holds information about a machine-to-machine token.
1741
*/
@@ -51,7 +75,7 @@ export class M2MToken {
5175
payload.jti ?? '', // jti should always be present in Clerk-issued M2M JWTs
5276
payload.sub,
5377
payload.scopes?.split(' ') ?? payload.aud ?? [],
54-
null,
78+
extractCustomClaims(payload),
5579
false,
5680
null,
5781
payload.exp * 1000 <= Date.now() - clockSkewInMs,

packages/backend/src/api/resources/__tests__/M2MToken.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ describe('M2MToken', () => {
2929
expect(token.id).toBe('mt_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE');
3030
expect(token.subject).toBe('mch_2vYVtestTESTtestTESTtestTESTtest');
3131
expect(token.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
32-
expect(token.claims).toBeNull();
32+
// `aud` is a user-supplied custom claim (the backend does not auto-add it),
33+
// so it is surfaced through `claims` while also seeding the `scopes` field.
34+
expect(token.claims).toEqual({ aud: ['mch_1xxxxx', 'mch_2xxxxx'] });
3335
expect(token.revoked).toBe(false);
3436
expect(token.revocationReason).toBeNull();
3537
expect(token.expired).toBe(false);
@@ -38,6 +40,42 @@ describe('M2MToken', () => {
3840
expect(token.updatedAt).toBe(1666648250 * 1000);
3941
});
4042

43+
it('preserves custom claims (including aud and scopes) and strips only structural claims', () => {
44+
const payload = {
45+
iss: 'https://clerk.m2m.example.test',
46+
sub: 'mch_2vYVtestTESTtestTESTtestTESTtest',
47+
aud: ['mch_1xxxxx'],
48+
exp: 1666648550,
49+
iat: 1666648250,
50+
nbf: 1666648240,
51+
jti: 'mt_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE',
52+
scopes: 'scope1 scope2',
53+
permissions: ['read:users', 'read:orders'],
54+
role: 'service',
55+
};
56+
57+
const token = M2MToken.fromJwtPayload(payload);
58+
59+
// `aud` and `scopes` are user-supplied custom claims in Clerk-issued M2M
60+
// tokens (the backend neither reserves nor auto-adds them), so they are
61+
// preserved in `claims` alongside any other custom claims.
62+
expect(token.claims).toEqual({
63+
aud: ['mch_1xxxxx'],
64+
scopes: 'scope1 scope2',
65+
permissions: ['read:users', 'read:orders'],
66+
role: 'service',
67+
});
68+
// Structural claims are mapped to dedicated fields, not leaked into `claims`.
69+
expect(token.claims).not.toHaveProperty('iss');
70+
expect(token.claims).not.toHaveProperty('sub');
71+
expect(token.claims).not.toHaveProperty('exp');
72+
expect(token.claims).not.toHaveProperty('nbf');
73+
expect(token.claims).not.toHaveProperty('iat');
74+
expect(token.claims).not.toHaveProperty('jti');
75+
// `scopes` is still derived onto the dedicated `scopes` field.
76+
expect(token.scopes).toEqual(['scope1', 'scope2']);
77+
});
78+
4179
it('prefers scopes claim over aud when both are present', () => {
4280
const payload = {
4381
sub: 'mch_test',

0 commit comments

Comments
 (0)