From 7cc23a02a579793da08bf204b57d867e1fb36ff9 Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Tue, 11 Aug 2026 13:53:00 +0200 Subject: [PATCH] fix(backend): Scope the JWKS cache per Clerk instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module-level JWKS cache was keyed on the bare `kid`. Because a Clerk `kid` is the instance id, a key cached for one instance was a direct hit for another instance's verification in the same process, and the lookup short-circuits before `secretKey` is consulted. Since `verifyJwt` never asserts `iss`, a session token minted by instance B authenticated against instance A in any process serving both — the documented Dynamic Keys / multi-tenant pattern. The same cache backs the M2M and OAuth sinks via `resolveKeyAndVerifyJwt`. Remote keys are now cached per `(apiUrl, apiVersion, secretKey)`, each namespace carrying its own TTL, so a cross-instance lookup misses and forces the secret-key-authenticated fetch. Local PEM keys move to their own store, which also stops a local `jwtKey` from disabling the remote TTL process-wide. The `jwk-kid-mismatch` message no longer enumerates cached kids, which disclosed the instance ids warm in a shared process. SDK-148 --- .changeset/scope-jwks-cache-per-instance.md | 7 ++ .../backend/src/tokens/__tests__/keys.test.ts | 88 ++++++++++++++++++- packages/backend/src/tokens/keys.ts | 71 ++++++++------- 3 files changed, 134 insertions(+), 32 deletions(-) create mode 100644 .changeset/scope-jwks-cache-per-instance.md diff --git a/.changeset/scope-jwks-cache-per-instance.md b/.changeset/scope-jwks-cache-per-instance.md new file mode 100644 index 00000000000..2b41b8ef249 --- /dev/null +++ b/.changeset/scope-jwks-cache-per-instance.md @@ -0,0 +1,7 @@ +--- +'@clerk/backend': patch +--- + +Scope the JWKS cache per Clerk instance. The cache was keyed on the JWT `kid` alone and shared across the whole process, so an application verifying tokens for more than one Clerk instance (for example the Dynamic Keys / multi-tenant pattern) could resolve a signing key that was fetched for a different instance. Keys are now cached separately per secret key and API URL, so a token can only be verified against the instance whose credentials fetched its signing key. + +The `jwk-kid-mismatch` error message no longer lists the key IDs currently held in the cache. diff --git a/packages/backend/src/tokens/__tests__/keys.test.ts b/packages/backend/src/tokens/__tests__/keys.test.ts index f4b301c9d53..f40719b8101 100644 --- a/packages/backend/src/tokens/__tests__/keys.test.ts +++ b/packages/backend/src/tokens/__tests__/keys.test.ts @@ -200,10 +200,96 @@ describe('tokens.loadClerkJWKFromRemote(options)', () => { kid, }), ).rejects.toThrowError( - "Unable to find a signing key in JWKS that matches the kid='ins_whatever' of the provided session token. Please make sure that the __session cookie or the HTTP authorization header contain a Clerk-generated session JWT. The following kid is available: ins_2GIoQhbUpy0hX7B2cVkuTMinXoD", + "Unable to find a signing key in JWKS that matches the kid='ins_whatever' of the provided session token. Please make sure that the __session cookie or the HTTP authorization header contain a Clerk-generated session JWT.", ); }); + // The cached kids are instance ids; enumerating them discloses which co-tenants + // are warm in a shared process. + it('does not enumerate cached kids in the error message', async () => { + server.use( + http.get( + 'https://api.clerk.com/v1/jwks', + validateHeaders(() => { + return HttpResponse.json(mockJwks); + }), + ), + ); + + const error = await loadClerkJWKFromRemote({ secretKey: 'deadbeef', kid: 'ins_whatever' }).catch(e => e); + + expect(error).toBeInstanceOf(TokenVerificationError); + expect(error.message).not.toContain(mockRsaJwkKid); + }); + + // Regression test for SDK-148. The cache was keyed on `kid` alone. Since a Clerk `kid` + // is the instance id and the lookup short-circuits before `secretKey` is consulted, a + // key fetched for one instance was served to another instance's verifier, and + // `verifyJwt` never asserts `iss`. + it('does not serve a cached key to a different secretKey', async () => { + const instanceAKid = 'ins_tenant_a'; + let secretKeysUsed: string[] = []; + + server.use( + http.get( + 'https://api.clerk.com/v1/jwks', + validateHeaders(({ request }) => { + secretKeysUsed.push((request.headers.get('Authorization') ?? '').replace('Bearer ', '')); + // Each instance's JWKS contains only its own signing key. + return HttpResponse.json({ keys: [{ ...mockRsaJwk, kid: instanceAKid }] }); + }), + ), + ); + + // Instance A warms the cache with its own key. + const jwk = await loadClerkJWKFromRemote({ secretKey: 'sk_test_a', kid: instanceAKid }); + expect(jwk).toMatchObject({ kid: instanceAKid }); + expect(secretKeysUsed).toEqual(['sk_test_a']); + + // Instance B asking for instance A's kid must miss the cache and fetch under its + // own secretKey. + secretKeysUsed = []; + server.use( + http.get( + 'https://api.clerk.com/v1/jwks', + validateHeaders(({ request }) => { + secretKeysUsed.push((request.headers.get('Authorization') ?? '').replace('Bearer ', '')); + return HttpResponse.json({ keys: [{ ...mockRsaJwk, kid: 'ins_tenant_b' }] }); + }), + ), + ); + + await expect(() => loadClerkJWKFromRemote({ secretKey: 'sk_test_b', kid: instanceAKid })).rejects.toThrowError( + TokenVerificationError, + ); + expect(secretKeysUsed).toEqual(['sk_test_b']); + }); + + it('keeps a separate cache TTL per instance', async () => { + let fetchCount = 0; + server.use( + http.get( + 'https://api.clerk.com/v1/jwks', + validateHeaders(() => { + fetchCount++; + return HttpResponse.json(mockJwks); + }), + ), + ); + + await loadClerkJWKFromRemote({ secretKey: 'sk_ttl_a', kid: mockRsaJwkKid }); + expect(fetchCount).toBe(1); + + // A second instance must not ride on the first instance's fresh TTL. + await loadClerkJWKFromRemote({ secretKey: 'sk_ttl_b', kid: mockRsaJwkKid }); + expect(fetchCount).toBe(2); + + // Each instance now serves from its own cache. + await loadClerkJWKFromRemote({ secretKey: 'sk_ttl_a', kid: mockRsaJwkKid }); + await loadClerkJWKFromRemote({ secretKey: 'sk_ttl_b', kid: mockRsaJwkKid }); + expect(fetchCount).toBe(2); + }); + it('cache TTLs do not conflict', async () => { server.use( http.get( diff --git a/packages/backend/src/tokens/keys.ts b/packages/backend/src/tokens/keys.ts index 64d487a8760..8d4080f516c 100644 --- a/packages/backend/src/tokens/keys.ts +++ b/packages/backend/src/tokens/keys.ts @@ -19,20 +19,33 @@ type JsonWebKeyWithKid = JsonWebKey & { kid: string }; type JsonWebKeyCache = Record; -let cache: JsonWebKeyCache = {}; -let lastUpdatedAt = 0; +type RemoteJwksCache = { + keys: JsonWebKeyCache; + lastUpdatedAt: number; +}; -function getFromCache(kid: string) { - return cache[kid]; -} +/** + * Remote JWKS caches, one per Clerk instance. A single process-wide cache keyed by `kid` + * alone hands one instance's signing key to another instance's verification: a Clerk `kid` + * is the instance id, the lookup short-circuits before `secretKey` is consulted, and + * `verifyJwt` does not assert `iss`. That let a session token minted by instance B + * authenticate against instance A in any process serving both. + */ +const remoteCaches = new Map(); -function getCacheValues() { - return Object.values(cache); -} +/** Local PEM keys are not tied to a secret key, and never expire. */ +const localCache: JsonWebKeyCache = {}; -function setInCache(cacheKey: string, jwk: JsonWebKeyWithKid, shouldExpire = true) { - cache[cacheKey] = jwk; - lastUpdatedAt = shouldExpire ? Date.now() : -1; +/** + * The scope is held in memory only as a Map key. It is never logged or surfaced in errors. + */ +function getRemoteCache(scope: string): RemoteJwksCache { + let cache = remoteCaches.get(scope); + if (!cache) { + cache = { keys: {}, lastUpdatedAt: 0 }; + remoteCaches.set(scope, cache); + } + return cache; } const PEM_HEADER = '-----BEGIN PUBLIC KEY-----'; @@ -56,7 +69,7 @@ export function loadClerkJwkFromPem(params: LoadClerkJwkFromPemOptions): JsonWeb // cache conflicts when loadClerkJwkFromPem and loadClerkJWKFromRemote // are called with the same kid const prefixedKid = `local-${kid}`; - const cachedJwk = getFromCache(prefixedKid); + const cachedJwk = localCache[prefixedKid]; if (cachedJwk) { return cachedJwk; @@ -81,7 +94,7 @@ export function loadClerkJwkFromPem(params: LoadClerkJwkFromPemOptions): JsonWeb // https://datatracker.ietf.org/doc/html/rfc7517 const jwk = { kid: prefixedKid, kty: 'RSA', alg: 'RS256', n: modulus, e: 'AQAB' }; - setInCache(prefixedKid, jwk, false); // local key never expires in cache + localCache[prefixedKid] = jwk; return jwk; } @@ -131,7 +144,9 @@ export type LoadClerkJWKFromRemoteOptions = { export async function loadClerkJWKFromRemote(params: LoadClerkJWKFromRemoteOptions): Promise { const { secretKey, apiUrl = API_URL, apiVersion = API_VERSION, kid, skipJwksCache } = params; - if (skipJwksCache || cacheHasExpired() || !getFromCache(kid)) { + const cache = getRemoteCache(`${apiUrl}|${apiVersion}|${secretKey ?? ''}`); + + if (skipJwksCache || cacheHasExpired(cache) || !cache.keys[kid]) { if (!secretKey) { throw new TokenVerificationError({ action: TokenVerificationErrorAction.ContactSupport, @@ -150,21 +165,20 @@ export async function loadClerkJWKFromRemote(params: LoadClerkJWKFromRemoteOptio }); } - keys.forEach(key => setInCache(key.kid, key)); + keys.forEach(key => { + cache.keys[key.kid] = key; + }); + cache.lastUpdatedAt = Date.now(); } - const jwk = getFromCache(kid); + const jwk = cache.keys[kid]; if (!jwk) { - const cacheValues = getCacheValues(); - const jwkKeys = cacheValues - .map(jwk => jwk.kid) - .sort() - .join(', '); - + // The available kids are deliberately omitted: they are instance ids, and enumerating + // them would disclose which co-tenants are warm in a shared process. throw new TokenVerificationError({ action: `Go to your Dashboard and validate your secret and public keys are correct. ${TokenVerificationErrorAction.ContactSupport} if the issue persists.`, - message: `Unable to find a signing key in JWKS that matches the kid='${kid}' of the provided session token. Please make sure that the __session cookie or the HTTP authorization header contain a Clerk-generated session JWT. The following kid is available: ${jwkKeys}`, + message: `Unable to find a signing key in JWKS that matches the kid='${kid}' of the provided session token. Please make sure that the __session cookie or the HTTP authorization header contain a Clerk-generated session JWT.`, reason: TokenVerificationErrorReason.JWKKidMismatch, }); } @@ -218,17 +232,12 @@ async function fetchJWKSFromBAPI(apiUrl: string, key: string, apiVersion: string return response.json(); } -function cacheHasExpired() { - // If lastUpdatedAt is -1, it means that we're using a local JWKS and it never expires - if (lastUpdatedAt === -1) { - return false; - } - +function cacheHasExpired(cache: RemoteJwksCache) { // If the cache has expired, clear the value so we don't attempt to make decisions based on stale data - const isExpired = Date.now() - lastUpdatedAt >= MAX_CACHE_LAST_UPDATED_AT_SECONDS * 1000; + const isExpired = Date.now() - cache.lastUpdatedAt >= MAX_CACHE_LAST_UPDATED_AT_SECONDS * 1000; if (isExpired) { - cache = {}; + cache.keys = {}; } return isExpired;