Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/m2m-jwt-custom-claims.md
Original file line numberDiff line numberDiff line change
@@ -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 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.
33 changes: 32 additions & 1 deletion packages/backend/src/api/__tests__/M2MTokenApi.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -417,7 +417,7 @@ describe('M2MToken', () => {
});
});

async function createSignedM2MJwt(payload = mockM2MJwtPayload) {
async function createSignedM2MJwt(payload: Record<string, unknown> = mockM2MJwtPayload) {
const { data } = await signJwt(payload, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
Expand DownExpand Up@@ -455,6 +455,37 @@ 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 });

// `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']);
});

it('throws when JWT signature cannot be verified', async () => {
const m2mApi = new M2MTokenApi(
buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }),
Expand Down
26 changes: 25 additions & 1 deletion packages/backend/src/api/resources/M2MToken.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,30 @@ type M2MJwtPayload = {
[key: string]: unknown;
};

// 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 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<string, any> | null {
const claims: Record<string, any> = {};
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.
*/
Expand DownExpand Up@@ -51,7 +75,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,
Expand Down
40 changes: 39 additions & 1 deletion packages/backend/src/api/resources/__tests__/M2MToken.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand All@@ -38,6 +40,42 @@ describe('M2MToken', () => {
expect(token.updatedAt).toBe(1666648250 * 1000);
});

it('preserves custom claims (including aud and scopes) and strips only structural 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);

// `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',
});
// 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('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']);
});

it('prefers scopes claim over aud when both are present', () => {
const payload = {
sub: 'mch_test',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(backend): preserve custom claims when verifying JWT M2M tokens by jacekradko · Pull Request #8697 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/m2m-jwt-custom-claims.md
Original file line numberDiff line numberDiff line change
@@ -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 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.
33 changes: 32 additions & 1 deletion packages/backend/src/api/__tests__/M2MTokenApi.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -417,7 +417,7 @@ describe('M2MToken', () => {
});
});

async function createSignedM2MJwt(payload = mockM2MJwtPayload) {
async function createSignedM2MJwt(payload: Record<string, unknown> = mockM2MJwtPayload) {
const { data } = await signJwt(payload, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
Expand DownExpand Up@@ -455,6 +455,37 @@ 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 });

// `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']);
});

it('throws when JWT signature cannot be verified', async () => {
const m2mApi = new M2MTokenApi(
buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }),
Expand Down
26 changes: 25 additions & 1 deletion packages/backend/src/api/resources/M2MToken.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,30 @@ type M2MJwtPayload = {
[key: string]: unknown;
};

// 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 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<string, any> | null {
const claims: Record<string, any> = {};
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.
*/
Expand DownExpand Up@@ -51,7 +75,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,
Expand Down
40 changes: 39 additions & 1 deletion packages/backend/src/api/resources/__tests__/M2MToken.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand All@@ -38,6 +40,42 @@ describe('M2MToken', () => {
expect(token.updatedAt).toBe(1666648250 * 1000);
});

it('preserves custom claims (including aud and scopes) and strips only structural 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);

// `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',
});
// 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('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']);
});

it('prefers scopes claim over aud when both are present', () => {
const payload = {
sub: 'mch_test',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(backend): preserve custom claims when verifying JWT M2M tokens by jacekradko · Pull Request #8697 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/m2m-jwt-custom-claims.md
Original file line numberDiff line numberDiff line change
@@ -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 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.
33 changes: 32 additions & 1 deletion packages/backend/src/api/__tests__/M2MTokenApi.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -417,7 +417,7 @@ describe('M2MToken', () => {
});
});

async function createSignedM2MJwt(payload = mockM2MJwtPayload) {
async function createSignedM2MJwt(payload: Record<string, unknown> = mockM2MJwtPayload) {
const { data } = await signJwt(payload, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
Expand DownExpand Up@@ -455,6 +455,37 @@ 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 });

// `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']);
});

it('throws when JWT signature cannot be verified', async () => {
const m2mApi = new M2MTokenApi(
buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }),
Expand Down
26 changes: 25 additions & 1 deletion packages/backend/src/api/resources/M2MToken.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,30 @@ type M2MJwtPayload = {
[key: string]: unknown;
};

// 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 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<string, any> | null {
const claims: Record<string, any> = {};
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.
*/
Expand DownExpand Up@@ -51,7 +75,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,
Expand Down
40 changes: 39 additions & 1 deletion packages/backend/src/api/resources/__tests__/M2MToken.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand All@@ -38,6 +40,42 @@ describe('M2MToken', () => {
expect(token.updatedAt).toBe(1666648250 * 1000);
});

it('preserves custom claims (including aud and scopes) and strips only structural 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);

// `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',
});
// 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('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']);
});

it('prefers scopes claim over aud when both are present', () => {
const payload = {
sub: 'mch_test',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(backend): preserve custom claims when verifying JWT M2M tokens by jacekradko · Pull Request #8697 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/m2m-jwt-custom-claims.md
Original file line numberDiff line numberDiff line change
@@ -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 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.
33 changes: 32 additions & 1 deletion packages/backend/src/api/__tests__/M2MTokenApi.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -417,7 +417,7 @@ describe('M2MToken', () => {
});
});

async function createSignedM2MJwt(payload = mockM2MJwtPayload) {
async function createSignedM2MJwt(payload: Record<string, unknown> = mockM2MJwtPayload) {
const { data } = await signJwt(payload, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
Expand DownExpand Up@@ -455,6 +455,37 @@ 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 });

// `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']);
});

it('throws when JWT signature cannot be verified', async () => {
const m2mApi = new M2MTokenApi(
buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }),
Expand Down
26 changes: 25 additions & 1 deletion packages/backend/src/api/resources/M2MToken.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,30 @@ type M2MJwtPayload = {
[key: string]: unknown;
};

// 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 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<string, any> | null {
const claims: Record<string, any> = {};
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.
*/
Expand DownExpand Up@@ -51,7 +75,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,
Expand Down
40 changes: 39 additions & 1 deletion packages/backend/src/api/resources/__tests__/M2MToken.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand All@@ -38,6 +40,42 @@ describe('M2MToken', () => {
expect(token.updatedAt).toBe(1666648250 * 1000);
});

it('preserves custom claims (including aud and scopes) and strips only structural 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);

// `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',
});
// 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('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']);
});

it('prefers scopes claim over aud when both are present', () => {
const payload = {
sub: 'mch_test',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(backend): preserve custom claims when verifying JWT M2M tokens by jacekradko · Pull Request #8697 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/m2m-jwt-custom-claims.md
Original file line numberDiff line numberDiff line change
@@ -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 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.
33 changes: 32 additions & 1 deletion packages/backend/src/api/__tests__/M2MTokenApi.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -417,7 +417,7 @@ describe('M2MToken', () => {
});
});

async function createSignedM2MJwt(payload = mockM2MJwtPayload) {
async function createSignedM2MJwt(payload: Record<string, unknown> = mockM2MJwtPayload) {
const { data } = await signJwt(payload, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
Expand DownExpand Up@@ -455,6 +455,37 @@ 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 });

// `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']);
});

it('throws when JWT signature cannot be verified', async () => {
const m2mApi = new M2MTokenApi(
buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }),
Expand Down
26 changes: 25 additions & 1 deletion packages/backend/src/api/resources/M2MToken.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,30 @@ type M2MJwtPayload = {
[key: string]: unknown;
};

// 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 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<string, any> | null {
const claims: Record<string, any> = {};
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.
*/
Expand DownExpand Up@@ -51,7 +75,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,
Expand Down
40 changes: 39 additions & 1 deletion packages/backend/src/api/resources/__tests__/M2MToken.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand All@@ -38,6 +40,42 @@ describe('M2MToken', () => {
expect(token.updatedAt).toBe(1666648250 * 1000);
});

it('preserves custom claims (including aud and scopes) and strips only structural 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);

// `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',
});
// 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('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']);
});

it('prefers scopes claim over aud when both are present', () => {
const payload = {
sub: 'mch_test',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(backend): preserve custom claims when verifying JWT M2M tokens by jacekradko · Pull Request #8697 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/m2m-jwt-custom-claims.md
Original file line numberDiff line numberDiff line change
@@ -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 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.
33 changes: 32 additions & 1 deletion packages/backend/src/api/__tests__/M2MTokenApi.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -417,7 +417,7 @@ describe('M2MToken', () => {
});
});

async function createSignedM2MJwt(payload = mockM2MJwtPayload) {
async function createSignedM2MJwt(payload: Record<string, unknown> = mockM2MJwtPayload) {
const { data } = await signJwt(payload, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
Expand DownExpand Up@@ -455,6 +455,37 @@ 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 });

// `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']);
});

it('throws when JWT signature cannot be verified', async () => {
const m2mApi = new M2MTokenApi(
buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }),
Expand Down
26 changes: 25 additions & 1 deletion packages/backend/src/api/resources/M2MToken.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,30 @@ type M2MJwtPayload = {
[key: string]: unknown;
};

// 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 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<string, any> | null {
const claims: Record<string, any> = {};
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.
*/
Expand DownExpand Up@@ -51,7 +75,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,
Expand Down
40 changes: 39 additions & 1 deletion packages/backend/src/api/resources/__tests__/M2MToken.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand All@@ -38,6 +40,42 @@ describe('M2MToken', () => {
expect(token.updatedAt).toBe(1666648250 * 1000);
});

it('preserves custom claims (including aud and scopes) and strips only structural 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);

// `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',
});
// 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('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']);
});

it('prefers scopes claim over aud when both are present', () => {
const payload = {
sub: 'mch_test',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(backend): preserve custom claims when verifying JWT M2M tokens by jacekradko · Pull Request #8697 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/m2m-jwt-custom-claims.md
Original file line numberDiff line numberDiff line change
@@ -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 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.
33 changes: 32 additions & 1 deletion packages/backend/src/api/__tests__/M2MTokenApi.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -417,7 +417,7 @@ describe('M2MToken', () => {
});
});

async function createSignedM2MJwt(payload = mockM2MJwtPayload) {
async function createSignedM2MJwt(payload: Record<string, unknown> = mockM2MJwtPayload) {
const { data } = await signJwt(payload, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
Expand DownExpand Up@@ -455,6 +455,37 @@ 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 });

// `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']);
});

it('throws when JWT signature cannot be verified', async () => {
const m2mApi = new M2MTokenApi(
buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }),
Expand Down
26 changes: 25 additions & 1 deletion packages/backend/src/api/resources/M2MToken.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,30 @@ type M2MJwtPayload = {
[key: string]: unknown;
};

// 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 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<string, any> | null {
const claims: Record<string, any> = {};
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.
*/
Expand DownExpand Up@@ -51,7 +75,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,
Expand Down
40 changes: 39 additions & 1 deletion packages/backend/src/api/resources/__tests__/M2MToken.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand All@@ -38,6 +40,42 @@ describe('M2MToken', () => {
expect(token.updatedAt).toBe(1666648250 * 1000);
});

it('preserves custom claims (including aud and scopes) and strips only structural 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);

// `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',
});
// 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('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']);
});

it('prefers scopes claim over aud when both are present', () => {
const payload = {
sub: 'mch_test',
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(backend): preserve custom claims when verifying JWT M2M tokens by jacekradko · Pull Request #8697 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/m2m-jwt-custom-claims.md
Original file line numberDiff line numberDiff line change
@@ -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 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.
33 changes: 32 additions & 1 deletion packages/backend/src/api/__tests__/M2MTokenApi.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -417,7 +417,7 @@ describe('M2MToken', () => {
});
});

async function createSignedM2MJwt(payload = mockM2MJwtPayload) {
async function createSignedM2MJwt(payload: Record<string, unknown> = mockM2MJwtPayload) {
const { data } = await signJwt(payload, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
Expand DownExpand Up@@ -455,6 +455,37 @@ 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 });

// `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']);
});

it('throws when JWT signature cannot be verified', async () => {
const m2mApi = new M2MTokenApi(
buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }),
Expand Down
26 changes: 25 additions & 1 deletion packages/backend/src/api/resources/M2MToken.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,30 @@ type M2MJwtPayload = {
[key: string]: unknown;
};

// 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 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<string, any> | null {
const claims: Record<string, any> = {};
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.
*/
Expand DownExpand Up@@ -51,7 +75,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,
Expand Down
40 changes: 39 additions & 1 deletion packages/backend/src/api/resources/__tests__/M2MToken.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand All@@ -38,6 +40,42 @@ describe('M2MToken', () => {
expect(token.updatedAt).toBe(1666648250 * 1000);
});

it('preserves custom claims (including aud and scopes) and strips only structural 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);

// `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',
});
// 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('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']);
});

it('prefers scopes claim over aud when both are present', () => {
const payload = {
sub: 'mch_test',
Expand Down
Loading