Skip to content

Commit 2ad205b

Browse files
dominic-clerkclaude
andcommitted
fix(backend): Reject non-session JWT categories as session tokens
Session tokens, handshake tokens, and JWT-template tokens are all signed with the same instance key, and only the `cat` protected-header tag distinguishes them. Nothing in the auth paths checked it, so a JWT-template token was accepted anywhere a session or handshake token was expected. authenticateRequest() now rejects a non-session category in the Authorization header and the __session cookie with token-type-mismatch (SEC-340), and verifyHandshakeJwt rejects one before signature verification (AISEC-85). Tokens with no `cat` and instances configured to omit the category are still accepted, so this is behaviour-preserving for anything minted before the category rollout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent daae528 commit 2ad205b

10 files changed

Lines changed: 159 additions & 15 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': patch
3+
---
4+
5+
Reject JWT-template tokens where a session or handshake token is expected. `authenticateRequest()` now returns a signed-out state with reason `token-type-mismatch` for such a token in the `Authorization` header or `__session` cookie. Tokens with no category tag, and instances configured to omit it, are unaffected.

packages/backend/src/jwt/verifyMachineJwt.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ import {
99
} from '../errors';
1010
import type { MachineTokenReturnType } from '../jwt/types';
1111
import { verifyJwt } from '../jwt/verifyJwt';
12+
import { JWT_CATEGORY_M2M_TOKEN } from '../tokens/jwtCategories';
1213
import type { LoadClerkJWKFromRemoteOptions } from '../tokens/keys';
1314
import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from '../tokens/keys';
14-
import { JWT_CATEGORY_M2M_TOKEN, OAUTH_ACCESS_TOKEN_TYPES } from '../tokens/machine';
15+
import { OAUTH_ACCESS_TOKEN_TYPES } from '../tokens/machine';
1516
import { TokenType } from '../tokens/tokenTypes';
1617

1718
export type JwtMachineVerifyOptions = Pick<LoadClerkJWKFromRemoteOptions, 'secretKey' | 'apiUrl' | 'skipJwksCache'> & {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { http, HttpResponse } from 'msw';
2+
import { beforeEach, describe, expect, it } from 'vitest';
3+
4+
import { mockJwks, mockRsaJwkKid, signingJwks } from '../../fixtures';
5+
import { signJwt } from '../../jwt/signJwt';
6+
import { server, validateHeaders } from '../../mock-server';
7+
import { verifyHandshakeToken } from '../handshake';
8+
import {
9+
JWT_CATEGORY_IGNORE,
10+
JWT_CATEGORY_JWT_TEMPLATE,
11+
JWT_CATEGORY_M2M_TOKEN,
12+
JWT_CATEGORY_SESSION_TOKEN,
13+
} from '../jwtCategories';
14+
15+
const directives = ['__session=foo; Path=/'];
16+
17+
async function createHandshakeJwt(cat: string | undefined, handshake = directives) {
18+
const { data } = await signJwt({ handshake }, signingJwks, {
19+
algorithm: 'RS256',
20+
header: { typ: 'JWT', kid: mockRsaJwkKid, ...(cat !== undefined ? { cat } : {}) },
21+
});
22+
return data!;
23+
}
24+
25+
function verify(token: string) {
26+
return verifyHandshakeToken(token, {
27+
apiUrl: 'https://api.clerk.test',
28+
secretKey: 'a-valid-key',
29+
skipJwksCache: true,
30+
});
31+
}
32+
33+
describe('tokens.verifyHandshakeToken(token, options)', () => {
34+
beforeEach(() => {
35+
server.use(
36+
http.get(
37+
'https://api.clerk.test/v1/jwks',
38+
validateHeaders(() => HttpResponse.json(mockJwks)),
39+
),
40+
);
41+
});
42+
43+
it.each([
44+
['the session-token category, which is what the handshake minter stamps', JWT_CATEGORY_SESSION_TOKEN],
45+
['the ignore category, used by instances that opt out of category tagging', JWT_CATEGORY_IGNORE],
46+
['no category, for tokens minted before the category rollout', undefined],
47+
])('verifies a handshake token with %s', async (_label, cat) => {
48+
await expect(verify(await createHandshakeJwt(cat))).resolves.toMatchObject({ handshake: directives });
49+
});
50+
51+
// Regression test for AISEC-85. A JWT template is the one customer-authorable producer of a
52+
// token carrying a top-level `handshake[]` claim, and resolveHandshake emits those entries
53+
// verbatim as Set-Cookie.
54+
it('rejects a JWT-template token presented as a handshake token', async () => {
55+
const token = await createHandshakeJwt(JWT_CATEGORY_JWT_TEMPLATE, ['ATTACKER_INJECTED=pwned; Path=/']);
56+
57+
await expect(verify(token)).rejects.toThrowError('Invalid handshake token category.');
58+
});
59+
60+
it.each([
61+
['m2m', JWT_CATEGORY_M2M_TOKEN],
62+
['unknown', 'cl_some_future_unknown_cat'],
63+
])('rejects a handshake token with a %s category', async (_label, cat) => {
64+
await expect(verify(await createHandshakeJwt(cat))).rejects.toThrowError('Invalid handshake token category.');
65+
});
66+
});

packages/backend/src/tokens/__tests__/request.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { signJwt } from '../../jwt/signJwt';
2121
import { server } from '../../mock-server';
2222
import type { AuthReason } from '../authStatus';
2323
import { AuthErrorReason, AuthStatus } from '../authStatus';
24+
import { JWT_CATEGORY_JWT_TEMPLATE } from '../jwtCategories';
2425
import { OrganizationMatcher } from '../organizationMatcher';
2526
import { authenticateRequest, RefreshTokenErrorReason } from '../request';
2627
import { type MachineTokenType, TokenType } from '../tokenTypes';
@@ -1306,6 +1307,37 @@ describe('tokens.authenticateRequest(options)', () => {
13061307
expect(requestState.toAuth()).toBeSignedOutToAuth();
13071308
});
13081309

1310+
// Regression tests for SEC-340. A JWT-template token is signed by the same instance key and
1311+
// passes verifyToken(), but carries no `sid`, so it outlives revocation of the session that
1312+
// minted it and must not authenticate one.
1313+
describe.each([
1314+
['headerToken', (jwt: string) => mockRequestWithHeaderAuth({ authorization: jwt })],
1315+
[
1316+
'cookieToken',
1317+
(jwt: string) =>
1318+
mockRequestWithCookies(
1319+
{},
1320+
{ __clerk_db_jwt: 'deadbeef', __client_uat: `${mockJwtPayload.iat - 10}`, __session: jwt },
1321+
),
1322+
],
1323+
])('%s: JWT-template token presented as a session token (SEC-340)', (_label, buildRequest) => {
1324+
test('returns signed out', async () => {
1325+
const { sid: _sid, ...payloadWithoutSid } = mockJwtPayload;
1326+
const { data: templateJwt } = await signJwt(payloadWithoutSid, signingJwks, {
1327+
algorithm: 'RS256',
1328+
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', cat: JWT_CATEGORY_JWT_TEMPLATE },
1329+
});
1330+
1331+
const requestState = await authenticateRequest(buildRequest(templateJwt!), mockOptions());
1332+
1333+
expect(requestState).toBeSignedOut({
1334+
reason: AuthErrorReason.TokenTypeMismatch,
1335+
message: '',
1336+
});
1337+
expect(requestState.toAuth()).toBeSignedOutToAuth();
1338+
});
1339+
});
1340+
13091341
// todo(
13101342
// 'cookieToken: returns signed in when cookieToken.iat >= clientUat and expired token and ssrToken [10y.2n.1y]',
13111343
// assert => {

packages/backend/src/tokens/__tests__/verify.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
} from '../../fixtures/machine';
1919
import { signJwt } from '../../jwt/signJwt';
2020
import { server, validateHeaders } from '../../mock-server';
21-
import { JWT_CATEGORY_M2M_TOKEN } from '../machine';
21+
import { JWT_CATEGORY_M2M_TOKEN } from '../jwtCategories';
2222
import { verifyMachineAuthToken, verifyToken } from '../verify';
2323

2424
async function createSignedOAuthJwt(

packages/backend/src/tokens/handshake.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { AuthenticateContext } from './authenticateContext';
77
import type { SignedInState, SignedOutState } from './authStatus';
88
import { AuthErrorReason, signedIn, signedOut } from './authStatus';
99
import { getCookieName, getCookieValue } from './cookie';
10+
import { isNonSessionJwtCategory } from './jwtCategories';
1011
import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys';
1112
import type { OrganizationMatcher } from './organizationMatcher';
1213
import { TokenType } from './tokenTypes';
@@ -28,6 +29,18 @@ async function verifyHandshakeJwt(token: string, { key }: VerifyJwtOptions): Pro
2829
assertHeaderType(typ);
2930
assertHeaderAlgorithm(alg);
3031

32+
// Handshake tokens are minted with the session-token category, so any other class signed by
33+
// the same instance key is not one. Without this a JWT-template token passes, and a template
34+
// can carry an author-controlled top-level `handshake[]` claim that resolveHandshake emits
35+
// verbatim as Set-Cookie (AISEC-85).
36+
if (isNonSessionJwtCategory(header.cat)) {
37+
throw new TokenVerificationError({
38+
action: TokenVerificationErrorAction.EnsureClerkJWT,
39+
reason: TokenVerificationErrorReason.TokenInvalid,
40+
message: 'Invalid handshake token category.',
41+
});
42+
}
43+
3144
const { data: signatureValid, errors: signatureErrors } = await hasValidSignature(decoded, key);
3245
if (signatureErrors) {
3346
throw new TokenVerificationError({
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { decodeJwt } from '../jwt/verifyJwt';
2+
3+
// Token-category tags in the protected JOSE header, distinguishing JWT classes signed by the
4+
// same instance key. Kept in sync with clerk_go (pkg/jwt/jwt.go).
5+
export const JWT_CATEGORY_SESSION_TOKEN = 'cl_B7d4PD111AAA';
6+
export const JWT_CATEGORY_JWT_TEMPLATE = 'cl_B7d4PD222AAA';
7+
export const JWT_CATEGORY_M2M_TOKEN = 'cl_B7d4PD333AAA';
8+
// Instances with `use_ignore_jwt_cat` stamp this on every class, so it carries no class info
9+
// and must not be discriminated on.
10+
export const JWT_CATEGORY_IGNORE = 'cl_I7d4PD111III';
11+
12+
/**
13+
* Whether `cat` marks a JWT as something other than a session token. Handshake tokens are
14+
* minted with the session-token category too. An absent `cat` is accepted for tokens minted
15+
* before the category rollout.
16+
*/
17+
export function isNonSessionJwtCategory(cat?: string): boolean {
18+
return cat !== undefined && cat !== JWT_CATEGORY_SESSION_TOKEN && cat !== JWT_CATEGORY_IGNORE;
19+
}
20+
21+
/** Malformed tokens return `false`; signature verification is left to reject them. */
22+
export function hasNonSessionJwtCategory(token: string): boolean {
23+
const { data, errors } = decodeJwt(token);
24+
return !errors && isNonSessionJwtCategory(data?.header?.cat);
25+
}

packages/backend/src/tokens/machine.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,6 @@ export const M2M_SUBJECT_PREFIX = 'mch_';
88
export const OAUTH_TOKEN_PREFIX = 'oat_';
99
export const API_KEY_PREFIX = 'ak_';
1010

11-
// Token-category tag in the protected JOSE header of instance-signed M2M JWTs,
12-
// used to distinguish them from other JWT classes signed by the same instance
13-
// key. Kept in sync with clerk_go (pkg/jwt) and cloudflare-workers.
14-
export const JWT_CATEGORY_M2M_TOKEN = 'cl_B7d4PD333AAA';
15-
1611
const MACHINE_TOKEN_PREFIXES = [M2M_TOKEN_PREFIX, OAUTH_TOKEN_PREFIX, API_KEY_PREFIX] as const;
1712

1813
export const JwtFormatRegExp = /^[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/;

packages/backend/src/tokens/request.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { AuthErrorReason, handshake, signedIn, signedOut, signedOutInvalidToken
1515
import { createClerkRequest } from './clerkRequest';
1616
import { getCookieName, getCookieValue } from './cookie';
1717
import { HandshakeService } from './handshake';
18+
import { hasNonSessionJwtCategory } from './jwtCategories';
1819
import { getMachineTokenType, isMachineJwt, isMachineToken, isTokenTypeAccepted } from './machine';
1920
import { OrganizationMatcher } from './organizationMatcher';
2021
import type { MachineTokenType, SessionTokenType } from './tokenTypes';
@@ -419,11 +420,12 @@ export const authenticateRequest: AuthenticateRequest = (async (
419420
async function authenticateRequestWithTokenInHeader() {
420421
const { tokenInHeader } = authenticateContext;
421422

422-
// Reject machine JWTs (OAuth or M2M) that may appear in headers when expecting session tokens.
423-
// These are valid Clerk-signed JWTs and will pass verify() verification,
424-
// but should not be accepted as session tokens.
423+
// Reject JWTs of another class (machine tokens, JWT templates) presented where a session
424+
// token is expected. They are validly signed by the same instance key and pass
425+
// verifyToken(), but a JWT-template token carries no `sid` and outlives revocation of the
426+
// session that minted it (SEC-340).
425427
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
426-
if (isMachineJwt(tokenInHeader!)) {
428+
if (isMachineJwt(tokenInHeader!) || hasNonSessionJwtCategory(tokenInHeader!)) {
427429
return signedOut({
428430
tokenType: TokenType.SessionToken,
429431
authenticateContext,
@@ -631,9 +633,14 @@ export const authenticateRequest: AuthenticateRequest = (async (
631633
return handleSessionTokenError(decodedErrors[0], 'cookie');
632634
}
633635

634-
// Machine JWTs pass verifyToken() but must not be accepted as session tokens (mirrors header path).
635-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
636-
if (isMachineJwt(authenticateContext.sessionTokenInCookie!)) {
636+
// Machine JWTs and JWT-template tokens pass verifyToken() but must not be accepted as
637+
// session tokens (mirrors header path).
638+
if (
639+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
640+
isMachineJwt(authenticateContext.sessionTokenInCookie!) ||
641+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
642+
hasNonSessionJwtCategory(authenticateContext.sessionTokenInCookie!)
643+
) {
637644
return signedOut({
638645
tokenType: TokenType.SessionToken,
639646
authenticateContext,

packages/backend/src/tokens/verify.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@ import type { VerifyJwtOptions } from '../jwt';
1414
import type { JwtReturnType, MachineTokenReturnType } from '../jwt/types';
1515
import { decodeJwt, verifyJwt } from '../jwt/verifyJwt';
1616
import { verifyM2MJwt, verifyOAuthJwt } from '../jwt/verifyMachineJwt';
17+
import { JWT_CATEGORY_M2M_TOKEN } from './jwtCategories';
1718
import type { LoadClerkJWKFromRemoteOptions } from './keys';
1819
import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys';
1920
import {
2021
API_KEY_PREFIX,
2122
isJwtFormat,
22-
JWT_CATEGORY_M2M_TOKEN,
2323
M2M_SUBJECT_PREFIX,
2424
M2M_TOKEN_PREFIX,
2525
OAUTH_ACCESS_TOKEN_TYPES,

0 commit comments

Comments
 (0)