From e4c08f1b3bf58335707bcb3b6c33a487f055a234 Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Thu, 15 Oct 2026 11:19:18 +0100 Subject: [PATCH 1/4] fix(backend): reject uncategorized JWTs presented as session tokens Clerk stamps every session token with a `cat` header, but OIDC id_tokens minted for third-party OAuth clients carry none while being signed by the same instance key. authenticateRequest() and verifyToken() now treat a missing category as non-session, so an id_token can no longer replay as a session for the user in `sub`. The handshake verifier keeps tolerating an absent category because the edge session-minter signs handshake tokens without one. Refs AISEC-21 Co-Authored-By: Claude Fable 5.1 --- .../reject-uncategorized-session-jwts.md | 5 ++ packages/backend/src/fixtures/index.ts | 11 ++-- .../src/tokens/__tests__/handshake.test.ts | 2 +- .../tokens/__tests__/handshakeToken.test.ts | 2 +- .../src/tokens/__tests__/request.test.ts | 43 +++++++++++++-- .../src/tokens/__tests__/request_azp.test.ts | 6 +-- .../src/tokens/__tests__/verify.test.ts | 54 +++++++++++++++++-- packages/backend/src/tokens/handshake.ts | 5 +- packages/backend/src/tokens/jwtCategories.ts | 9 ++-- packages/backend/src/tokens/request.ts | 11 ++-- packages/backend/src/tokens/verify.ts | 9 ++-- 11 files changed, 124 insertions(+), 33 deletions(-) create mode 100644 .changeset/reject-uncategorized-session-jwts.md diff --git a/.changeset/reject-uncategorized-session-jwts.md b/.changeset/reject-uncategorized-session-jwts.md new file mode 100644 index 00000000000..18730ba5f37 --- /dev/null +++ b/.changeset/reject-uncategorized-session-jwts.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Reject JWTs that carry no `cat` header where a session token is expected. Clerk stamps every session token with a category, so a validly signed JWT without one, such as an OIDC `id_token` issued to a third-party OAuth client, is not a session token. `authenticateRequest()` now returns a signed-out state with reason `token-type-mismatch` for such a token in the `Authorization` header or `__session` cookie, and `verifyToken()` returns a `token-invalid` error. JWT-template tokens remain verifiable through `verifyToken()`. Self-signed test tokens must now include `cat: 'cl_B7d4PD111AAA'` in their header to be accepted as session tokens. diff --git a/packages/backend/src/fixtures/index.ts b/packages/backend/src/fixtures/index.ts index 7802d39dce7..f8905c1e2fa 100644 --- a/packages/backend/src/fixtures/index.ts +++ b/packages/backend/src/fixtures/index.ts @@ -2,17 +2,17 @@ import { base64url } from '../util/rfc4648'; // signed with signingJwks export const mockJwt = - 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODMxMCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.j3rB92k32WqbQDkFB093H4GoQsBVLH4HLGF6ObcwUaVGiHC8SEu6T31FuPf257SL8A5sSGtWWM1fqhQpdLohgZb_hbJswGBuYI-Clxl9BtpIRHbWFZkLBIj8yS9W9aVtD3fWBbF6PHx7BY1udio-rbGWg1YAOZNtVcxF02p-MvX-8XIK92Vwu3Un5zyfCoVIg__qo3Xntzw3tznsZ4XDe212c6kVz1R_L1d5DKjeWXpjUPAS_zFeZSIJEQLf4JNr4JCY38tfdnc3ajfDA3p36saf1XwmTdWXQKCXi75c2TJAXROs3Pgqr5Kw_5clygoFuxN5OEMhFWFSnvIBdi3M6w'; + 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QiLCJjYXQiOiJjbF9CN2Q0UEQxMTFBQUEifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODMxMCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.ByJ10T3PqSo2BEHr384QSitFK2_dyIwt1AGILFwwCecVN_BHOMF44zjBBTeW1118dkdpTMYM7pe43NEmvjQqXbyESv_AjB11B-2jwt54zPoKJyYRHxSNli4vbXXixFvXMIcYEHf-vpnoiUVjtjcHt5A72fJZI-Cs-C_Xschqti5aYm2CzFbNBlNdUaychFolZ7VlggKy9wGBTaIJDZbZX-hBWOSXotZittAEgH_W6yUtc3Qq5UlRgCGSL-ESmu-YTXFXQpq5oRg8JPWTvY1prixi8PAMZ8HWJl2VpEcEd8zysgAcBzIET_ktiRMzs2DjioOZmO10_TTkgpd7ARImXg'; // signed with signingJwks, same as mockJwt but with lower iat and exp values export const mockExpiredJwt = - 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODIwMCwiaWF0IjoxNjY2NjQ3MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.jLImjg2vGwOJDkK9gtIeJnEJWVOgoCMeC46OFtfzJ1d8OT0KVvwRppC60QIMfHKoOwLTLYlq8SccrkARwlJ_jOvMAYMGZT-R4qHoEfGmet1cSTC67zaafq5gpf9759x1kNMyckry_PJNSx-9hTFbBMWhY7XVLVlrauppqHXOQr1-BC7u-0InzKjCQTCJj-81Yt8xRKweLbO689oYSRAFYK5LNH8BYoLZFWuWLO-6nxUJu0_XAq9xpZPqZOqj3LxFS4hHVGGmTqnPgR8vBetLXxSLAOBsEyIkeQkOBA03YA6enTNIppmy0XTLgAYmUO_JWOGjjjDQoEojuXtuLRdQHQ'; + 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QiLCJjYXQiOiJjbF9CN2Q0UEQxMTFBQUEifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODIwMCwiaWF0IjoxNjY2NjQ3MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.DvtvOiys4VMrVaGCIKsJwN636zYlarVxqoK6LdzM4D-FE31uMYEslj59GMJS2_MdudSdzFs3Dr-CrPAF1kd65BYKl9S_F-U8xRAdEzbkaYOEBWHWRqeip2rb62o3Kz9jPNfVozZDW2SGxAKcBO4UWcoe_lUNpOzteYsx5SXA2K0zUVlzqQulhqUazAZgdb-yP6Is_rq9owG9iwLt36ac3s9ln6a2GhNt-fPNLQLwTKFRxGBsl1PMbSHME6sKtoaaGvXrQbaP5_GAVQ2xp6gXHlnG3KqR4lZ9OaQDZGpZOBzKzXhFHn8ZA4--_P6dTHta9NbtFt8y9tPOh5jhUer-GA'; export const mockInvalidSignatureJwt = - 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLnRhbXBlcmVkLWRvbWFpbi5kZXYiLCJleHAiOjE2NjY2NDgzMTAsImlhdCI6MTY2NjY0ODI1MCwiaXNzIjoiaHR0cHM6Ly9jbGVyay5pbnNwaXJlZC5wdW1hLTc0LmxjbC5kZXYiLCJuYmYiOjE2NjY2NDgyNDAsInNpZCI6InNlc3NfMkdiREI0ZW5OZENhNXZTMXpwQzNYemc5dEs5Iiwic3ViIjoidXNlcl8yR0lwWE9FcFZ5Snc1MXJrWm45S21uYzZTeHIifQ.n1Usc-DLDftqA0Xb-_2w8IGs4yjCmwc5RngwbSRvwevuZOIuRoeHmE2sgCdEvjfJEa7ewL6EVGVcM557TWPW--g_J1XQPwBy8tXfz7-S73CEuyRFiR97L2AHRdvRtvGtwR-o6l8aHaFxtlmfWbQXfg4kFJz2UGe9afmh3U9-f_4JOZ5fa3mI98UMy1-bo20vjXeWQ9aGrqaxHQxjnzzC-1Kpi5LdPvhQ16H0dPB8MHRTSM5TAuLKTpPV7wqixmbtcc2-0k6b9FKYZNqRVTaIyV-lifZloBvdzlfOF8nW1VVH_fx-iW5Q3hovHFcJIULHEC1kcAYTubbxzpgeVQepGg'; + 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QiLCJjYXQiOiJjbF9CN2Q0UEQxMTFBQUEifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLnRhbXBlcmVkLWRvbWFpbi5kZXYiLCJleHAiOjE2NjY2NDgzMTAsImlhdCI6MTY2NjY0ODI1MCwiaXNzIjoiaHR0cHM6Ly9jbGVyay5pbnNwaXJlZC5wdW1hLTc0LmxjbC5kZXYiLCJuYmYiOjE2NjY2NDgyNDAsInNpZCI6InNlc3NfMkdiREI0ZW5OZENhNXZTMXpwQzNYemc5dEs5Iiwic3ViIjoidXNlcl8yR0lwWE9FcFZ5Snc1MXJrWm45S21uYzZTeHIifQ.n1Usc-DLDftqA0Xb-_2w8IGs4yjCmwc5RngwbSRvwevuZOIuRoeHmE2sgCdEvjfJEa7ewL6EVGVcM557TWPW--g_J1XQPwBy8tXfz7-S73CEuyRFiR97L2AHRdvRtvGtwR-o6l8aHaFxtlmfWbQXfg4kFJz2UGe9afmh3U9-f_4JOZ5fa3mI98UMy1-bo20vjXeWQ9aGrqaxHQxjnzzC-1Kpi5LdPvhQ16H0dPB8MHRTSM5TAuLKTpPV7wqixmbtcc2-0k6b9FKYZNqRVTaIyV-lifZloBvdzlfOF8nW1VVH_fx-iW5Q3hovHFcJIULHEC1kcAYTubbxzpgeVQepGg'; export const mockMalformedJwt = - 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJpYXQiOjE2NjY2NDgyNTB9.n1Usc-DLDftqA0Xb-_2w8IGs4yjCmwc5RngwbSRvwevuZOIuRoeHmE2sgCdEvjfJEa7ewL6EVGVcM557TWPW--g_J1XQPwBy8tXfz7-S73CEuyRFiR97L2AHRdvRtvGtwR-o6l8aHaFxtlmfWbQXfg4kFJz2UGe9afmh3U9-f_4JOZ5fa3mI98UMy1-bo20vjXeWQ9aGrqaxHQxjnzzC-1Kpi5LdPvhQ16H0dPB8MHRTSM5TAuLKTpPV7wqixmbtcc2-0k6b9FKYZNqRVTaIyV-lifZloBvdzlfOF8nW1VVH_fx-iW5Q3hovHFcJIULHEC1kcAYTubbxzpgeVQepGg'; + 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QiLCJjYXQiOiJjbF9CN2Q0UEQxMTFBQUEifQ.eyJpYXQiOjE2NjY2NDgyNTB9.n1Usc-DLDftqA0Xb-_2w8IGs4yjCmwc5RngwbSRvwevuZOIuRoeHmE2sgCdEvjfJEa7ewL6EVGVcM557TWPW--g_J1XQPwBy8tXfz7-S73CEuyRFiR97L2AHRdvRtvGtwR-o6l8aHaFxtlmfWbQXfg4kFJz2UGe9afmh3U9-f_4JOZ5fa3mI98UMy1-bo20vjXeWQ9aGrqaxHQxjnzzC-1Kpi5LdPvhQ16H0dPB8MHRTSM5TAuLKTpPV7wqixmbtcc2-0k6b9FKYZNqRVTaIyV-lifZloBvdzlfOF8nW1VVH_fx-iW5Q3hovHFcJIULHEC1kcAYTubbxzpgeVQepGg'; const mockJwtSignature = 'n1Usc-DLDftqA0Xb-_2w8IGs4yjCmwc5RngwbSRvwevuZOIuRoeHmE2sgCdEvjfJEa7ewL6EVGVcM557TWPW--g_J1XQPwBy8tXfz7-S73CEuyRFiR97L2AHRdvRtvGtwR-o6l8aHaFxtlmfWbQXfg4kFJz2UGe9afmh3U9-f_4JOZ5fa3mI98UMy1-bo20vjXeWQ9aGrqaxHQxjnzzC-1Kpi5LdPvhQ16H0dPB8MHRTSM5TAuLKTpPV7wqixmbtcc2-0k6b9FKYZNqRVTaIyV-lifZloBvdzlfOF8nW1VVH_fx-iW5Q3hovHFcJIULHEC1kcAYTubbxzpgeVQepGg'; @@ -21,6 +21,7 @@ export const mockJwtHeader = { alg: 'RS256', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', typ: 'JWT', + cat: 'cl_B7d4PD111AAA', }; export const mockJwtPayload = { @@ -170,7 +171,7 @@ export const publicJwks = { // this jwt has be signed with the keys above. The payload is mockJwtPayload and the header is mockJwtHeader export const signedJwt = - 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODMxMCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.j3rB92k32WqbQDkFB093H4GoQsBVLH4HLGF6ObcwUaVGiHC8SEu6T31FuPf257SL8A5sSGtWWM1fqhQpdLohgZb_hbJswGBuYI-Clxl9BtpIRHbWFZkLBIj8yS9W9aVtD3fWBbF6PHx7BY1udio-rbGWg1YAOZNtVcxF02p-MvX-8XIK92Vwu3Un5zyfCoVIg__qo3Xntzw3tznsZ4XDe212c6kVz1R_L1d5DKjeWXpjUPAS_zFeZSIJEQLf4JNr4JCY38tfdnc3ajfDA3p36saf1XwmTdWXQKCXi75c2TJAXROs3Pgqr5Kw_5clygoFuxN5OEMhFWFSnvIBdi3M6w'; + 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QiLCJjYXQiOiJjbF9CN2Q0UEQxMTFBQUEifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODMxMCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.ByJ10T3PqSo2BEHr384QSitFK2_dyIwt1AGILFwwCecVN_BHOMF44zjBBTeW1118dkdpTMYM7pe43NEmvjQqXbyESv_AjB11B-2jwt54zPoKJyYRHxSNli4vbXXixFvXMIcYEHf-vpnoiUVjtjcHt5A72fJZI-Cs-C_Xschqti5aYm2CzFbNBlNdUaychFolZ7VlggKy9wGBTaIJDZbZX-hBWOSXotZittAEgH_W6yUtc3Qq5UlRgCGSL-ESmu-YTXFXQpq5oRg8JPWTvY1prixi8PAMZ8HWJl2VpEcEd8zysgAcBzIET_ktiRMzs2DjioOZmO10_TTkgpd7ARImXg'; export const pkTest = 'pk_test_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; export const pkLive = 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; diff --git a/packages/backend/src/tokens/__tests__/handshake.test.ts b/packages/backend/src/tokens/__tests__/handshake.test.ts index 07f23e59f12..b10ba549a5d 100644 --- a/packages/backend/src/tokens/__tests__/handshake.test.ts +++ b/packages/backend/src/tokens/__tests__/handshake.test.ts @@ -25,7 +25,7 @@ vi.mock('../verify.js', async importOriginal => { vi.mock('../../jwt/verifyJwt.js', () => ({ decodeJwt: vi.fn().mockReturnValue({ data: { - header: { typ: 'JWT', alg: 'RS256', kid: 'test-kid' }, + header: { typ: 'JWT', alg: 'RS256', kid: 'test-kid', cat: 'cl_B7d4PD111AAA' }, payload: { sub: 'user_123', __raw: 'raw-token', diff --git a/packages/backend/src/tokens/__tests__/handshakeToken.test.ts b/packages/backend/src/tokens/__tests__/handshakeToken.test.ts index 94c88df2b33..6e10b7cbf64 100644 --- a/packages/backend/src/tokens/__tests__/handshakeToken.test.ts +++ b/packages/backend/src/tokens/__tests__/handshakeToken.test.ts @@ -43,7 +43,7 @@ describe('tokens.verifyHandshakeToken(token, options)', () => { it.each([ ['the session-token category, which is what the handshake minter stamps', JWT_CATEGORY_SESSION_TOKEN], ['the ignore category, used by instances that opt out of category tagging', JWT_CATEGORY_IGNORE], - ['no category, for tokens minted before the category rollout', undefined], + ['no category, which the edge session-minter handshake signer does not stamp', undefined], ])('verifies a handshake token with %s', async (_label, cat) => { await expect(verify(await createHandshakeJwt(cat))).resolves.toMatchObject({ handshake: directives }); }); diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 27ab1a55047..836b7f0dad0 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -21,7 +21,7 @@ import { signJwt } from '../../jwt/signJwt'; import { server } from '../../mock-server'; import type { AuthReason } from '../authStatus'; import { AuthErrorReason, AuthStatus } from '../authStatus'; -import { JWT_CATEGORY_JWT_TEMPLATE } from '../jwtCategories'; +import { JWT_CATEGORY_JWT_TEMPLATE, JWT_CATEGORY_M2M_TOKEN, JWT_CATEGORY_SESSION_TOKEN } from '../jwtCategories'; import { OrganizationMatcher } from '../organizationMatcher'; import { authenticateRequest, RefreshTokenErrorReason } from '../request'; import { type MachineTokenType, TokenType } from '../tokenTypes'; @@ -1234,7 +1234,7 @@ describe('tokens.authenticateRequest(options)', () => { const { sub: _, ...payloadWithoutSub } = mockJwtPayload; const { data: malformedJwt } = await signJwt(payloadWithoutSub, signingJwks, { algorithm: 'RS256', - header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, + header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', cat: JWT_CATEGORY_SESSION_TOKEN }, }); const requestState = await authenticateRequest( @@ -1285,7 +1285,7 @@ describe('tokens.authenticateRequest(options)', () => { // A same-instance M2M JWT carries the instance issuer, so it survives the suffixed-cookie check. const { data: m2mJwt } = await signJwt({ ...mockM2MJwtPayload, iss: mockJwtPayload.iss }, signingJwks, { algorithm: 'RS256', - header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, + header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', cat: JWT_CATEGORY_M2M_TOKEN }, }); const requestState = await authenticateRequest( @@ -1346,6 +1346,43 @@ describe('tokens.authenticateRequest(options)', () => { }); }); + describe.each([ + ['headerToken', templateInHeader, {}], + [ + 'cookieToken', + (jwt: string) => + mockRequestWithCookies( + {}, + { __clerk_db_jwt: 'deadbeef', __client_uat: `${mockJwtPayload.iat - 10}`, __session: jwt }, + ), + {}, + ], + ['headerToken, acceptsToken: any', templateInHeader, { acceptsToken: 'any' as const }], + [ + 'headerToken, acceptsToken: [session_token, m2m_token]', + templateInHeader, + { acceptsToken: ['session_token', 'm2m_token'] as const }, + ], + ])('%s: OIDC id_token presented as a session token (AISEC-21)', (_label, buildRequest, options) => { + test('returns signed out', async () => { + server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks))); + const { azp: _azp, ...payloadWithoutAzp } = mockJwtPayload; + const { data: idToken } = await signJwt( + { ...payloadWithoutAzp, aud: 'client_2VTWUzvGC5UhdJCNx6xG1D98edc' }, + signingJwks, + { algorithm: 'RS256', header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' } }, + ); + + const requestState = await authenticateRequest(buildRequest(idToken!), mockOptions(options)); + + expect(requestState).toBeSignedOut({ + reason: AuthErrorReason.TokenTypeMismatch, + message: '', + }); + expect(requestState.toAuth()).toBeSignedOutToAuth(); + }); + }); + // todo( // 'cookieToken: returns signed in when cookieToken.iat >= clientUat and expired token and ssrToken [10y.2n.1y]', // assert => { diff --git a/packages/backend/src/tokens/__tests__/request_azp.test.ts b/packages/backend/src/tokens/__tests__/request_azp.test.ts index d4aadb142a0..e2c883fe86e 100644 --- a/packages/backend/src/tokens/__tests__/request_azp.test.ts +++ b/packages/backend/src/tokens/__tests__/request_azp.test.ts @@ -31,7 +31,7 @@ describe('authenticateRequest with cookie token', () => { }); vi.mocked(decodeJwt).mockReturnValue({ - data: { payload } as any, + data: { header: { cat: 'cl_B7d4PD111AAA' }, payload } as any, errors: undefined, }); @@ -79,7 +79,7 @@ describe('authenticateRequest with cookie token', () => { }); vi.mocked(decodeJwt).mockReturnValue({ - data: { payload } as any, + data: { header: { cat: 'cl_B7d4PD111AAA' }, payload } as any, errors: undefined, }); @@ -121,7 +121,7 @@ describe('authenticateRequest with header token', () => { }); vi.mocked(decodeJwt).mockReturnValue({ - data: { payload } as any, + data: { header: { cat: 'cl_B7d4PD111AAA' }, payload } as any, errors: undefined, }); diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 6ff8e915a52..9ad39444a77 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -19,7 +19,7 @@ import { } from '../../fixtures/machine'; import { signJwt } from '../../jwt/signJwt'; import { server, validateHeaders } from '../../mock-server'; -import { JWT_CATEGORY_M2M_TOKEN } from '../jwtCategories'; +import { JWT_CATEGORY_IGNORE, JWT_CATEGORY_JWT_TEMPLATE, JWT_CATEGORY_M2M_TOKEN } from '../jwtCategories'; import { verifyMachineAuthToken, verifyToken } from '../verify'; async function createSignedOAuthJwt( @@ -35,11 +35,11 @@ async function createSignedOAuthJwt( async function createSignedM2MJwt( payload: Record = mockM2MJwtPayload, - cat: string | undefined = JWT_CATEGORY_M2M_TOKEN, + cat: string | null = JWT_CATEGORY_M2M_TOKEN, ) { const { data } = await signJwt(payload, signingJwks, { algorithm: 'RS256', - header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', ...(cat !== undefined ? { cat } : {}) }, + header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', ...(cat !== null ? { cat } : {}) }, }); return data!; } @@ -146,6 +146,50 @@ describe('tokens.verify(token, options)', () => { expect(errors?.[0].reason).toBe('token-invalid'); expect(errors?.[0].message).toBe('Invalid session token category.'); }); + + it('rejects an OIDC id_token, which carries no cat header, before key resolution (AISEC-21)', async () => { + const { azp: _azp, ...payloadWithoutAzp } = mockJwtPayload; + const token = await createSignedM2MJwt({ ...payloadWithoutAzp, aud: 'client_2VTWUzvGC5UhdJCNx6xG1D98edc' }, null); + + const { data, errors } = await verifyToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + skipJwksCache: true, + }); + + expect(data).toBeUndefined(); + expect(errors?.[0].reason).toBe('token-invalid'); + expect(errors?.[0].message).toBe('Invalid session token category.'); + }); + + it('still verifies a JWT-template token for direct callers', async () => { + server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks))); + const { sid: _sid, ...payloadWithoutSid } = mockJwtPayload; + const token = await createSignedM2MJwt(payloadWithoutSid, JWT_CATEGORY_JWT_TEMPLATE); + + const { data, errors } = await verifyToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + skipJwksCache: true, + }); + + expect(errors).toBeUndefined(); + expect(data).toEqual(payloadWithoutSid); + }); + + it('verifies a session JWT carrying the ignore category', async () => { + server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks))); + const token = await createSignedM2MJwt(mockJwtPayload, JWT_CATEGORY_IGNORE); + + const { data, errors } = await verifyToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + skipJwksCache: true, + }); + + expect(errors).toBeUndefined(); + expect(data).toEqual(mockJwtPayload); + }); }); describe('tokens.verifyMachineAuthToken(token, options)', () => { @@ -877,7 +921,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { ); const m2mJwt = createJwt({ - header: { typ: 'JWT', alg: 'none', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, + header: { typ: 'JWT', alg: 'none', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', cat: JWT_CATEGORY_M2M_TOKEN }, payload: mockM2MJwtPayload, }); @@ -926,7 +970,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { ), ); - const m2mJwt = await createSignedM2MJwt(mockM2MJwtPayload, undefined); + const m2mJwt = await createSignedM2MJwt(mockM2MJwtPayload, null); const result = await verifyMachineAuthToken(m2mJwt, { apiUrl: 'https://api.clerk.test', diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts index 9c6afb45db2..5dc9b3a2c2f 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -32,8 +32,9 @@ async function verifyHandshakeJwt(token: string, { key }: VerifyJwtOptions): Pro // Handshake tokens are minted with the session-token category, so any other class signed by // the same instance key is not one. Without this a JWT-template token passes, and a template // can carry an author-controlled top-level `handshake[]` claim that resolveHandshake emits - // verbatim as Set-Cookie (AISEC-85). - if (isNonSessionJwtCategory(header.cat)) { + // verbatim as Set-Cookie (AISEC-85). An absent category is still tolerated here, unlike the + // session paths, because the edge session-minter signs handshake tokens without one. + if (header.cat !== undefined && isNonSessionJwtCategory(header.cat)) { throw new TokenVerificationError({ action: TokenVerificationErrorAction.EnsureClerkJWT, reason: TokenVerificationErrorReason.TokenInvalid, diff --git a/packages/backend/src/tokens/jwtCategories.ts b/packages/backend/src/tokens/jwtCategories.ts index f76eaf0baa8..74442ab596b 100644 --- a/packages/backend/src/tokens/jwtCategories.ts +++ b/packages/backend/src/tokens/jwtCategories.ts @@ -10,12 +10,13 @@ export const JWT_CATEGORY_M2M_TOKEN = 'cl_B7d4PD333AAA'; export const JWT_CATEGORY_IGNORE = 'cl_I7d4PD111III'; /** - * Whether `cat` marks a JWT as something other than a session token. Handshake tokens are - * minted with the session-token category too. An absent `cat` is accepted for tokens minted - * before the category rollout. + * Whether `cat` marks a JWT as something other than a session token. An absent `cat` is + * non-session: every session token carries one, while OIDC `id_token`s signed by the same + * instance key do not (AISEC-21). Handshake tokens are minted with the session-token category + * too, but their verifier tolerates an absent one. */ export function isNonSessionJwtCategory(cat?: string): boolean { - return cat !== undefined && cat !== JWT_CATEGORY_SESSION_TOKEN && cat !== JWT_CATEGORY_IGNORE; + return cat !== JWT_CATEGORY_SESSION_TOKEN && cat !== JWT_CATEGORY_IGNORE; } /** Malformed tokens return `false`; signature verification is left to reject them. */ diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 07f16f0a97e..bd0a74895f8 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -420,10 +420,11 @@ export const authenticateRequest: AuthenticateRequest = (async ( async function authenticateRequestWithTokenInHeader() { const { tokenInHeader } = authenticateContext; - // Reject JWTs of another class (machine tokens, JWT templates) presented where a session - // token is expected. They are validly signed by the same instance key and pass - // verifyToken(), but a JWT-template token carries no `sid` and outlives revocation of the - // session that minted it (SEC-340). + // Reject JWTs of another class (machine tokens, JWT templates, uncategorized tokens such as + // OIDC id_tokens) presented where a session token is expected. They are validly signed by + // the same instance key, but a JWT-template token carries no `sid` and outlives revocation + // of the session that minted it (SEC-340), and an id_token is a delegation to a third-party + // OAuth client, not a session (AISEC-21). // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (isMachineJwt(tokenInHeader!) || hasNonSessionJwtCategory(tokenInHeader!)) { return signedOut({ @@ -633,7 +634,7 @@ export const authenticateRequest: AuthenticateRequest = (async ( return handleSessionTokenError(decodedErrors[0], 'cookie'); } - // Machine JWTs and JWT-template tokens pass verifyToken() but must not be accepted as + // Machine JWTs, JWT-template tokens and uncategorized tokens must not be accepted as // session tokens (mirrors header path). if ( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index b20387f93d4..1ed485cd71d 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -14,7 +14,7 @@ import type { VerifyJwtOptions } from '../jwt'; import type { JwtReturnType, MachineTokenReturnType } from '../jwt/types'; import { decodeJwt, verifyJwt } from '../jwt/verifyJwt'; import { verifyM2MJwt, verifyOAuthJwt } from '../jwt/verifyMachineJwt'; -import { JWT_CATEGORY_M2M_TOKEN } from './jwtCategories'; +import { isNonSessionJwtCategory, JWT_CATEGORY_JWT_TEMPLATE } from './jwtCategories'; import type { LoadClerkJWKFromRemoteOptions } from './keys'; import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys'; import { @@ -120,9 +120,10 @@ export async function verifyToken( const { header } = decodedResult; const { kid } = header; - // Reject machine JWTs (e.g. M2M) tagged with a non-session category but signed by the same - // instance key, regardless of transport. Reciprocal of the machine verifier's `cat` check. - if (header.cat === JWT_CATEGORY_M2M_TOKEN) { + // Reject JWTs signed by the same instance key that are not session tokens: machine JWTs + // (reciprocal of the machine verifier's `cat` check) and uncategorized tokens such as OIDC + // `id_token`s (AISEC-21). JWT-template tokens stay verifiable here for direct callers. + if (header.cat !== JWT_CATEGORY_JWT_TEMPLATE && isNonSessionJwtCategory(header.cat)) { return { errors: [ new TokenVerificationError({ From d6e23ac27926e7ef242c05f4f4ac32695b83d8c4 Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Thu, 15 Oct 2026 15:56:12 +0100 Subject: [PATCH 2/4] test(e2e): stamp session category on integration test tokens Co-Authored-By: Claude Fable 5.1 --- integration/testUtils/handshake.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/testUtils/handshake.ts b/integration/testUtils/handshake.ts index 5df95bfe6e3..66518401075 100644 --- a/integration/testUtils/handshake.ts +++ b/integration/testUtils/handshake.ts @@ -138,7 +138,7 @@ export function generateConfig({ mode, matchedKeys = true }: { mode: 'test' | 'l return { token: jwt.sign(claims, rsa.private, { algorithm: 'RS256', - header: { kid: ins_id }, + header: { kid: ins_id, cat: 'cl_B7d4PD111AAA' }, }), claims, }; From 169b8ef20abe55f78bc25415b62a4b578ba0ac7f Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Mon, 19 Oct 2026 12:04:39 +0100 Subject: [PATCH 3/4] Make this a minor version bump --- .changeset/reject-uncategorized-session-jwts.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/reject-uncategorized-session-jwts.md b/.changeset/reject-uncategorized-session-jwts.md index 18730ba5f37..b49b503375c 100644 --- a/.changeset/reject-uncategorized-session-jwts.md +++ b/.changeset/reject-uncategorized-session-jwts.md @@ -1,5 +1,5 @@ --- -'@clerk/backend': patch +'@clerk/backend': minor --- -Reject JWTs that carry no `cat` header where a session token is expected. Clerk stamps every session token with a category, so a validly signed JWT without one, such as an OIDC `id_token` issued to a third-party OAuth client, is not a session token. `authenticateRequest()` now returns a signed-out state with reason `token-type-mismatch` for such a token in the `Authorization` header or `__session` cookie, and `verifyToken()` returns a `token-invalid` error. JWT-template tokens remain verifiable through `verifyToken()`. Self-signed test tokens must now include `cat: 'cl_B7d4PD111AAA'` in their header to be accepted as session tokens. +JWTs without a `cat` header are no longer accepted as session tokens: `authenticateRequest()` returns signed out (`token-type-mismatch`) and `verifyToken()` returns `token-invalid`. This includes OIDC `id_token`s issued by Clerk. Tests that sign their own session tokens must add `cat: 'cl_B7d4PD111AAA'` to the header. From 5e6074e3cfeeb82f307089d79741c5ee5ede7689 Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Mon, 19 Oct 2026 12:10:01 +0100 Subject: [PATCH 4/4] Use constants instead of hardcoding values --- packages/backend/src/fixtures/index.ts | 3 ++- .../backend/src/tokens/__tests__/request_azp.test.ts | 7 ++++--- packages/backend/src/tokens/__tests__/verify.test.ts | 11 ++++++++--- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/fixtures/index.ts b/packages/backend/src/fixtures/index.ts index f8905c1e2fa..cf651d5eb46 100644 --- a/packages/backend/src/fixtures/index.ts +++ b/packages/backend/src/fixtures/index.ts @@ -1,3 +1,4 @@ +import { JWT_CATEGORY_SESSION_TOKEN } from '../tokens/jwtCategories'; import { base64url } from '../util/rfc4648'; // signed with signingJwks @@ -21,7 +22,7 @@ export const mockJwtHeader = { alg: 'RS256', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', typ: 'JWT', - cat: 'cl_B7d4PD111AAA', + cat: JWT_CATEGORY_SESSION_TOKEN, }; export const mockJwtPayload = { diff --git a/packages/backend/src/tokens/__tests__/request_azp.test.ts b/packages/backend/src/tokens/__tests__/request_azp.test.ts index e2c883fe86e..327694a539e 100644 --- a/packages/backend/src/tokens/__tests__/request_azp.test.ts +++ b/packages/backend/src/tokens/__tests__/request_azp.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, vi } from 'vitest'; import { decodeJwt } from '../../jwt/verifyJwt'; +import { JWT_CATEGORY_SESSION_TOKEN } from '../jwtCategories'; import { authenticateRequest } from '../request'; import { verifyToken } from '../verify'; @@ -31,7 +32,7 @@ describe('authenticateRequest with cookie token', () => { }); vi.mocked(decodeJwt).mockReturnValue({ - data: { header: { cat: 'cl_B7d4PD111AAA' }, payload } as any, + data: { header: { cat: JWT_CATEGORY_SESSION_TOKEN }, payload } as any, errors: undefined, }); @@ -79,7 +80,7 @@ describe('authenticateRequest with cookie token', () => { }); vi.mocked(decodeJwt).mockReturnValue({ - data: { header: { cat: 'cl_B7d4PD111AAA' }, payload } as any, + data: { header: { cat: JWT_CATEGORY_SESSION_TOKEN }, payload } as any, errors: undefined, }); @@ -121,7 +122,7 @@ describe('authenticateRequest with header token', () => { }); vi.mocked(decodeJwt).mockReturnValue({ - data: { header: { cat: 'cl_B7d4PD111AAA' }, payload } as any, + data: { header: { cat: JWT_CATEGORY_SESSION_TOKEN }, payload } as any, errors: undefined, }); diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 9ad39444a77..2edf8f3c8af 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -19,7 +19,12 @@ import { } from '../../fixtures/machine'; import { signJwt } from '../../jwt/signJwt'; import { server, validateHeaders } from '../../mock-server'; -import { JWT_CATEGORY_IGNORE, JWT_CATEGORY_JWT_TEMPLATE, JWT_CATEGORY_M2M_TOKEN } from '../jwtCategories'; +import { + JWT_CATEGORY_IGNORE, + JWT_CATEGORY_JWT_TEMPLATE, + JWT_CATEGORY_M2M_TOKEN, + JWT_CATEGORY_SESSION_TOKEN, +} from '../jwtCategories'; import { verifyMachineAuthToken, verifyToken } from '../verify'; async function createSignedOAuthJwt( @@ -982,8 +987,8 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => { }); describe.each([ - ['session-token', 'cl_B7d4PD111AAA'], - ['jwt-template', 'cl_B7d4PD222AAA'], + ['session-token', JWT_CATEGORY_SESSION_TOKEN], + ['jwt-template', JWT_CATEGORY_JWT_TEMPLATE], ['unknown', 'cl_some_future_unknown_cat'], ])('rejects M2M JWT masquerading with a non-M2M cat', (label, cat) => { it(`rejects cat=${label} without attempting signature verification`, async () => {