Skip to content

Commit 8ee6a32

Browse files
fix(backend): Verify signature before claims (#8332)
1 parent 45b773a commit 8ee6a32

File tree

4 files changed

+63
-17
lines changed

4 files changed

+63
-17
lines changed

.changeset/five-eagles-tap.md

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+
The JWT claims are verified after the signature to avoid leaking information through error messages on forged tokens.

packages/backend/src/jwt/verifyJwt.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -145,20 +145,12 @@ export async function verifyJwt(
145145

146146
assertHeaderType(typ, headerType);
147147
assertHeaderAlgorithm(alg);
148-
149-
// Payload verifications
150-
const { azp, sub, aud, iat, exp, nbf } = payload;
151-
152-
assertSubClaim(sub);
153-
assertAudienceClaim([aud], [audience]);
154-
assertAuthorizedPartiesClaim(azp, authorizedParties);
155-
assertExpirationClaim(exp, clockSkew);
156-
assertActivationClaim(nbf, clockSkew);
157-
assertIssuedAtClaim(iat, clockSkew);
158148
} catch (err) {
159149
return { errors: [err as TokenVerificationError] };
160150
}
161151

152+
// Verify signature before validating claims to prevent oracle attacks
153+
// that could leak configuration details through differential error responses
162154
const { data: signatureValid, errors: signatureErrors } = await hasValidSignature(decoded, key);
163155
if (signatureErrors) {
164156
return {
@@ -183,5 +175,19 @@ export async function verifyJwt(
183175
};
184176
}
185177

178+
// Payload verifications (only after signature is confirmed valid)
179+
try {
180+
const { azp, sub, aud, iat, exp, nbf } = payload;
181+
182+
assertSubClaim(sub);
183+
assertAudienceClaim([aud], [audience]);
184+
assertAuthorizedPartiesClaim(azp, authorizedParties);
185+
assertExpirationClaim(exp, clockSkew);
186+
assertActivationClaim(nbf, clockSkew);
187+
assertIssuedAtClaim(iat, clockSkew);
188+
} catch (err) {
189+
return { errors: [err as TokenVerificationError] };
190+
}
191+
186192
return { data: payload };
187193
}

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import {
88
mockJwks,
99
mockJwt,
1010
mockJwtPayload,
11-
mockMalformedJwt,
11+
signingJwks,
1212
} from '../../fixtures';
1313
import {
1414
mockMachineAuthResponses,
1515
mockSignedOAuthAccessTokenJwt,
1616
mockTokens,
1717
mockVerificationResults,
1818
} from '../../fixtures/machine';
19+
import { signJwt } from '../../jwt/signJwt';
1920
import { server } from '../../mock-server';
2021
import type { AuthReason } from '../authStatus';
2122
import { AuthErrorReason, AuthStatus } from '../authStatus';
@@ -1193,13 +1194,20 @@ describe('tokens.authenticateRequest(options)', () => {
11931194
}),
11941195
);
11951196

1197+
// Create a properly signed JWT that is missing the 'sub' claim
1198+
const { sub: _, ...payloadWithoutSub } = mockJwtPayload;
1199+
const { data: malformedJwt } = await signJwt(payloadWithoutSub, signingJwks, {
1200+
algorithm: 'RS256',
1201+
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
1202+
});
1203+
11961204
const requestState = await authenticateRequest(
11971205
mockRequestWithCookies(
11981206
{},
11991207
{
12001208
__clerk_db_jwt: 'deadbeef',
12011209
__client_uat: `${mockJwtPayload.iat - 10}`,
1202-
__session: mockMalformedJwt,
1210+
__session: malformedJwt!,
12031211
},
12041212
),
12051213
mockOptions(),

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

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,15 @@ import { signJwt } from '../../jwt/signJwt';
2020
import { server, validateHeaders } from '../../mock-server';
2121
import { verifyMachineAuthToken, verifyToken } from '../verify';
2222

23-
function createOAuthJwt(
23+
async function createSignedOAuthJwt(
2424
payload = mockOAuthAccessTokenJwtPayload,
2525
typ: 'at+jwt' | 'application/at+jwt' | 'JWT' = 'at+jwt',
2626
) {
27-
return createJwt({
27+
const { data } = await signJwt(payload, signingJwks, {
28+
algorithm: 'RS256',
2829
header: { typ, kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
29-
payload,
3030
});
31+
return data!;
3132
}
3233

3334
async function createSignedM2MJwt(payload = mockM2MJwtPayload) {
@@ -85,6 +86,32 @@ describe('tokens.verify(token, options)', () => {
8586

8687
expect(data).toEqual(mockJwtPayload);
8788
});
89+
90+
it('returns signature error before claims error when both are invalid', async () => {
91+
server.use(
92+
http.get(
93+
'https://api.clerk.test/v1/jwks',
94+
validateHeaders(() => {
95+
return HttpResponse.json(mockJwks);
96+
}),
97+
),
98+
);
99+
100+
// Create a JWT with expired claims AND an invalid signature
101+
const expiredJwt = createJwt({
102+
payload: { ...mockJwtPayload, exp: mockJwtPayload.iat - 100 },
103+
});
104+
105+
const { errors } = await verifyToken(expiredJwt, {
106+
apiUrl: 'https://api.clerk.test',
107+
secretKey: 'a-valid-key',
108+
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
109+
skipJwksCache: true,
110+
});
111+
112+
expect(errors).toBeDefined();
113+
expect(errors?.[0].message).toContain('signature');
114+
});
88115
});
89116

90117
describe('tokens.verifyMachineAuthToken(token, options)', () => {
@@ -392,7 +419,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
392419
),
393420
);
394421

395-
const oauthJwt = createOAuthJwt(mockOAuthAccessTokenJwtPayload, 'JWT');
422+
const oauthJwt = await createSignedOAuthJwt(mockOAuthAccessTokenJwtPayload, 'JWT');
396423

397424
const result = await verifyMachineAuthToken(oauthJwt, {
398425
apiUrl: 'https://api.clerk.test',
@@ -472,7 +499,7 @@ describe('tokens.verifyMachineAuthToken(token, options)', () => {
472499
exp: mockOAuthAccessTokenJwtPayload.iat - 100,
473500
};
474501

475-
const oauthJwt = createOAuthJwt(expiredPayload, 'at+jwt');
502+
const oauthJwt = await createSignedOAuthJwt(expiredPayload);
476503

477504
const result = await verifyMachineAuthToken(oauthJwt, {
478505
apiUrl: 'https://api.clerk.test',

0 commit comments

Comments
 (0)