Skip to content

Commit 305f4ee

Browse files
authored
fix(backend): Support storing multiple local keys in JWK cache (#6993)
1 parent 29201b2 commit 305f4ee

File tree

8 files changed

+131
-67
lines changed

8 files changed

+131
-67
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@clerk/backend': patch
3+
'@clerk/shared': patch
4+
---
5+
6+
Fixed JWT public key caching in `verifyToken()` to support multi-instance scenarios. Public keys are now correctly cached per `kid` from the token header instead of using a single shared cache key.
7+
8+
**What was broken:**
9+
10+
When verifying JWT tokens with the `jwtKey` option (PEM public key), all keys were cached under the same cache key. This caused verification failures in multi-instance scenarios.
11+
12+
**What's fixed:**
13+
14+
JWT public keys are now cached using the `kid` value from each token's header.

packages/backend/src/fixtures/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export const mockPEMKey =
5252
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8Z1oLQbaYkakUSIYRvjmOoeXMDFFjynGP2+gVy0mQJHYgVhgo34RsQgZoz7rSNm/EOL+l/mHTqQAhwaf9Ef8X5vsPX8vP3RNRRm3XYpbIGbOcANJaHihJZwnzG9zIGYF8ki+m55zftO7pkOoXDtIqCt+5nIUQjGJK5axFELrnWaz2qcR03A7rYKQc3F1gut2Ru1xfmiJVUlQe0tLevQO/FzfYpWu7+691q+ZRUGxWvGc0ays4ACa7JXElCIKXRv/yb3Vc1iry77HRAQ28J7Fqpj5Cb+sxfFI+Vhf1GB1bNeOLPR10nkSMJ74HB0heHi/SsM83JiGekv0CpZPCC8jcQIDAQAB';
5353

5454
export const mockPEMJwk = {
55-
kid: 'local',
55+
kid: 'local-test-kid',
5656
kty: 'RSA',
5757
alg: 'RS256',
5858
n: '8Z1oLQbaYkakUSIYRvjmOoeXMDFFjynGP2-gVy0mQJHYgVhgo34RsQgZoz7rSNm_EOL-l_mHTqQAhwaf9Ef8X5vsPX8vP3RNRRm3XYpbIGbOcANJaHihJZwnzG9zIGYF8ki-m55zftO7pkOoXDtIqCt-5nIUQjGJK5axFELrnWaz2qcR03A7rYKQc3F1gut2Ru1xfmiJVUlQe0tLevQO_FzfYpWu7-691q-ZRUGxWvGc0ays4ACa7JXElCIKXRv_yb3Vc1iry77HRAQ28J7Fqpj5Cb-sxfFI-Vhf1GB1bNeOLPR10nkSMJ74HB0heHi_SsM83JiGekv0CpZPCC8jcQ',

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

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ import {
1212
mockRsaJwkKid,
1313
} from '../../fixtures';
1414
import { server, validateHeaders } from '../../mock-server';
15-
import { loadClerkJWKFromLocal, loadClerkJWKFromRemote } from '../keys';
15+
import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from '../keys';
16+
17+
const MOCK_KID = 'test-kid';
1618

1719
describe('tokens.loadClerkJWKFromLocal(localKey)', () => {
1820
it('throws an error if no key has been provided', () => {
19-
expect(() => loadClerkJWKFromLocal()).toThrow(
21+
expect(() => loadClerkJwkFromPem({ kid: MOCK_KID })).toThrow(
2022
new TokenVerificationError({
2123
action: TokenVerificationErrorAction.SetClerkJWTKey,
2224
message: 'Missing local JWK.',
@@ -26,14 +28,57 @@ describe('tokens.loadClerkJWKFromLocal(localKey)', () => {
2628
});
2729

2830
it('loads the local key', () => {
29-
const jwk = loadClerkJWKFromLocal(mockPEMKey);
31+
const jwk = loadClerkJwkFromPem({ kid: MOCK_KID, pem: mockPEMKey });
3032
expect(jwk).toMatchObject(mockPEMJwk);
3133
});
3234

3335
it('loads the local key in PEM format', () => {
34-
const jwk = loadClerkJWKFromLocal(mockPEMJwtKey);
36+
const jwk = loadClerkJwkFromPem({ kid: MOCK_KID, pem: mockPEMJwtKey });
3537
expect(jwk).toMatchObject(mockPEMJwk);
3638
});
39+
40+
it('caches PEM keys separately for different kids', () => {
41+
const jwk1 = loadClerkJwkFromPem({ kid: 'ins_1', pem: mockPEMKey }) as JsonWebKey & { kid: string };
42+
expect(jwk1.kid).toBe('local-ins_1');
43+
expect(jwk1.n).toBe(mockPEMJwk.n);
44+
45+
const jwk2 = loadClerkJwkFromPem({ kid: 'ins_2', pem: mockPEMJwtKey }) as JsonWebKey & { kid: string };
46+
expect(jwk2.kid).toBe('local-ins_2');
47+
expect(jwk2.n).toBe(mockPEMJwk.n);
48+
49+
// Verify both are cached independently
50+
const jwk1Cached = loadClerkJwkFromPem({ kid: 'ins_1', pem: mockPEMKey });
51+
const jwk2Cached = loadClerkJwkFromPem({ kid: 'ins_2', pem: mockPEMJwtKey });
52+
53+
expect(jwk1Cached).toBe(jwk1);
54+
expect(jwk2Cached).toBe(jwk2); // Same object reference means its cached
55+
});
56+
57+
it('returns cached JWK on subsequent calls with same kid', () => {
58+
const jwk1 = loadClerkJwkFromPem({ kid: 'cache-test', pem: mockPEMKey });
59+
const jwk2 = loadClerkJwkFromPem({ kid: 'cache-test', pem: mockPEMKey });
60+
// Should return the exact same reference
61+
expect(jwk1).toBe(jwk2);
62+
});
63+
64+
it('uses "local-" prefix to avoid cache collision with remote keys', () => {
65+
const localJwk = loadClerkJwkFromPem({ kid: 'test-kid', pem: mockPEMKey }) as JsonWebKey & { kid: string };
66+
expect(localJwk.kid).toBe('local-test-kid');
67+
});
68+
69+
it('creates separate cache entries for different kids even with same PEM', () => {
70+
// Two JWT keys might theoretically use the same PEM (unlikely but possible)
71+
const jwkA = loadClerkJwkFromPem({ kid: 'ins_key_a', pem: mockPEMKey }) as JsonWebKey & { kid: string };
72+
const jwkB = loadClerkJwkFromPem({ kid: 'ins_key_b', pem: mockPEMKey }) as JsonWebKey & { kid: string };
73+
74+
// They should be different objects
75+
expect(jwkA).not.toBe(jwkB);
76+
// But have the same modulus
77+
expect(jwkA.n).toBe(jwkB.n);
78+
// And different prefixed kids
79+
expect(jwkA.kid).toBe('local-ins_key_a');
80+
expect(jwkB.kid).toBe('local-ins_key_b');
81+
});
3782
});
3883

3984
describe('tokens.loadClerkJWKFromRemote(options)', () => {

packages/backend/src/tokens/handshake.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +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 { loadClerkJWKFromLocal, loadClerkJWKFromRemote } from './keys';
10+
import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys';
1111
import type { OrganizationMatcher } from './organizationMatcher';
1212
import { TokenType } from './tokenTypes';
1313
import type { OrganizationSyncOptions, OrganizationSyncTarget } from './types';
@@ -66,7 +66,7 @@ export async function verifyHandshakeToken(
6666
let key;
6767

6868
if (jwtKey) {
69-
key = loadClerkJWKFromLocal(jwtKey);
69+
key = loadClerkJwkFromPem({ kid, pem: jwtKey });
7070
} else if (secretKey) {
7171
// Fetch JWKS from Backend API using the key
7272
key = await loadClerkJWKFromRemote({ secretKey, apiUrl, apiVersion, kid, jwksCacheTtlInMs, skipJwksCache });
@@ -78,9 +78,7 @@ export async function verifyHandshakeToken(
7878
});
7979
}
8080

81-
return await verifyHandshakeJwt(token, {
82-
key,
83-
});
81+
return verifyHandshakeJwt(token, { key });
8482
}
8583

8684
export class HandshakeService {

packages/backend/src/tokens/keys.ts

Lines changed: 43 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -30,58 +30,59 @@ function getCacheValues() {
3030
return Object.values(cache);
3131
}
3232

33-
function setInCache(jwk: JsonWebKeyWithKid, shouldExpire = true) {
34-
cache[jwk.kid] = jwk;
33+
function setInCache(cacheKey: string, jwk: JsonWebKeyWithKid, shouldExpire = true) {
34+
cache[cacheKey] = jwk;
3535
lastUpdatedAt = shouldExpire ? Date.now() : -1;
3636
}
3737

38-
const LocalJwkKid = 'local';
3938
const PEM_HEADER = '-----BEGIN PUBLIC KEY-----';
4039
const PEM_TRAILER = '-----END PUBLIC KEY-----';
4140
const RSA_PREFIX = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA';
4241
const RSA_SUFFIX = 'IDAQAB';
4342

43+
type LoadClerkJwkFromPemOptions = {
44+
kid: string;
45+
pem?: string;
46+
};
47+
4448
/**
45-
*
4649
* Loads a local PEM key usually from process.env and transform it to JsonWebKey format.
47-
* The result is also cached on the module level to avoid unnecessary computations in subsequent invocations.
48-
*
49-
* @param {string} localKey
50-
* @returns {JsonWebKey} key
50+
* The result is cached on the module level to avoid unnecessary computations in subsequent invocations.
5151
*/
52-
export function loadClerkJWKFromLocal(localKey?: string): JsonWebKey {
53-
if (!getFromCache(LocalJwkKid)) {
54-
if (!localKey) {
55-
throw new TokenVerificationError({
56-
action: TokenVerificationErrorAction.SetClerkJWTKey,
57-
message: 'Missing local JWK.',
58-
reason: TokenVerificationErrorReason.LocalJWKMissing,
59-
});
60-
}
52+
export function loadClerkJwkFromPem(params: LoadClerkJwkFromPemOptions): JsonWebKey {
53+
const { kid, pem } = params;
54+
55+
// We use a cache key that includes the local prefix in order to avoid
56+
// cache conflicts when loadClerkJwkFromPem and loadClerkJWKFromRemote
57+
// are called with the same kid
58+
const prefixedKid = `local-${kid}`;
59+
const cachedJwk = getFromCache(prefixedKid);
60+
61+
if (cachedJwk) {
62+
return cachedJwk;
63+
}
6164

62-
const modulus = localKey
63-
.replace(/\r\n|\n|\r/g, '')
64-
.replace(PEM_HEADER, '')
65-
.replace(PEM_TRAILER, '')
66-
.replace(RSA_PREFIX, '')
67-
.replace(RSA_SUFFIX, '')
68-
.replace(/\+/g, '-')
69-
.replace(/\//g, '_');
70-
71-
// JWK https://datatracker.ietf.org/doc/html/rfc7517
72-
setInCache(
73-
{
74-
kid: 'local',
75-
kty: 'RSA',
76-
alg: 'RS256',
77-
n: modulus,
78-
e: 'AQAB',
79-
},
80-
false, // local key never expires in cache
81-
);
65+
if (!pem) {
66+
throw new TokenVerificationError({
67+
action: TokenVerificationErrorAction.SetClerkJWTKey,
68+
message: 'Missing local JWK.',
69+
reason: TokenVerificationErrorReason.LocalJWKMissing,
70+
});
8271
}
8372

84-
return getFromCache(LocalJwkKid);
73+
const modulus = pem
74+
.replace(/\r\n|\n|\r/g, '')
75+
.replace(PEM_HEADER, '')
76+
.replace(PEM_TRAILER, '')
77+
.replace(RSA_PREFIX, '')
78+
.replace(RSA_SUFFIX, '')
79+
.replace(/\+/g, '-')
80+
.replace(/\//g, '_');
81+
82+
// https://datatracker.ietf.org/doc/html/rfc7517
83+
const jwk = { kid: prefixedKid, kty: 'RSA', alg: 'RS256', n: modulus, e: 'AQAB' };
84+
setInCache(prefixedKid, jwk, false); // local key never expires in cache
85+
return jwk;
8586
}
8687

8788
/**
@@ -127,13 +128,9 @@ export type LoadClerkJWKFromRemoteOptions = {
127128
* @param {string} options.alg - The algorithm of the JWT
128129
* @returns {JsonWebKey} key
129130
*/
130-
export async function loadClerkJWKFromRemote({
131-
secretKey,
132-
apiUrl = API_URL,
133-
apiVersion = API_VERSION,
134-
kid,
135-
skipJwksCache,
136-
}: LoadClerkJWKFromRemoteOptions): Promise<JsonWebKey> {
131+
export async function loadClerkJWKFromRemote(params: LoadClerkJWKFromRemoteOptions): Promise<JsonWebKey> {
132+
const { secretKey, apiUrl = API_URL, apiVersion = API_VERSION, kid, skipJwksCache } = params;
133+
137134
if (skipJwksCache || cacheHasExpired() || !getFromCache(kid)) {
138135
if (!secretKey) {
139136
throw new TokenVerificationError({
@@ -153,7 +150,7 @@ export async function loadClerkJWKFromRemote({
153150
});
154151
}
155152

156-
keys.forEach(key => setInCache(key));
153+
keys.forEach(key => setInCache(key.kid, key));
157154
}
158155

159156
const jwk = getFromCache(kid);

packages/backend/src/tokens/verify.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { isClerkAPIResponseError } from '@clerk/shared/error';
2+
import type { Simplify } from '@clerk/shared/types';
23
import type { JwtPayload } from '@clerk/types';
34

45
import type { APIKey, IdPOAuthAccessToken, M2MToken } from '../api';
@@ -14,21 +15,23 @@ import type { VerifyJwtOptions } from '../jwt';
1415
import type { JwtReturnType, MachineTokenReturnType } from '../jwt/types';
1516
import { decodeJwt, verifyJwt } from '../jwt/verifyJwt';
1617
import type { LoadClerkJWKFromRemoteOptions } from './keys';
17-
import { loadClerkJWKFromLocal, loadClerkJWKFromRemote } from './keys';
18+
import { loadClerkJwkFromPem, loadClerkJWKFromRemote } from './keys';
1819
import { API_KEY_PREFIX, M2M_TOKEN_PREFIX, OAUTH_TOKEN_PREFIX } from './machine';
1920
import type { MachineTokenType } from './tokenTypes';
2021
import { TokenType } from './tokenTypes';
2122

2223
/**
2324
* @interface
2425
*/
25-
export type VerifyTokenOptions = Omit<VerifyJwtOptions, 'key'> &
26-
Omit<LoadClerkJWKFromRemoteOptions, 'kid'> & {
27-
/**
28-
* Used to verify the session token in a networkless manner. Supply the PEM public key from the **[**API keys**](https://dashboard.clerk.com/last-active?path=api-keys) page -> Show JWT public key -> PEM Public Key** section in the Clerk Dashboard. **It's recommended to use [the environment variable](https://clerk.com/docs/guides/development/clerk-environment-variables) instead.** For more information, refer to [Manual JWT verification](https://clerk.com/docs/guides/sessions/manual-jwt-verification).
29-
*/
30-
jwtKey?: string;
31-
};
26+
export type VerifyTokenOptions = Simplify<
27+
Omit<VerifyJwtOptions, 'key'> &
28+
Omit<LoadClerkJWKFromRemoteOptions, 'kid'> & {
29+
/**
30+
* Used to verify the session token in a networkless manner. Supply the PEM public key from the **[**API keys**](https://dashboard.clerk.com/last-active?path=api-keys) page -> Show JWT public key -> PEM Public Key** section in the Clerk Dashboard. **It's recommended to use [the environment variable](https://clerk.com/docs/guides/development/clerk-environment-variables) instead.** For more information, refer to [Manual JWT verification](https://clerk.com/docs/guides/sessions/manual-jwt-verification).
31+
*/
32+
jwtKey?: string;
33+
}
34+
>;
3235

3336
/**
3437
* > [!WARNING]
@@ -121,10 +124,10 @@ export async function verifyToken(
121124
const { kid } = header;
122125

123126
try {
124-
let key;
127+
let key: JsonWebKey;
125128

126129
if (options.jwtKey) {
127-
key = loadClerkJWKFromLocal(options.jwtKey);
130+
key = loadClerkJwkFromPem({ kid, pem: options.jwtKey });
128131
} else if (options.secretKey) {
129132
// Fetch JWKS from Backend API using the key
130133
key = await loadClerkJWKFromRemote({ ...options, kid });

packages/shared/src/types/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export type _unstable_mock_type = any;
1+
export type { Simplify } from './utils';

packages/shared/src/types/utils.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Useful to flatten the type output to improve type hints shown in editors. And also to transform an interface into a type to aide with assignability.
3+
* https://github.com/sindresorhus/type-fest/blob/main/source/simplify.d.ts
4+
*/
5+
export type Simplify<T> = {
6+
[K in keyof T]: T[K];
7+
} & {};

0 commit comments

Comments
 (0)