-
Notifications
You must be signed in to change notification settings - Fork 465
fix(backend): Scope the JWKS cache per Clerk instance #9394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,20 +19,33 @@ type JsonWebKeyWithKid = JsonWebKey & { kid: string }; | |
|
|
||
| type JsonWebKeyCache = Record<string, JsonWebKeyWithKid>; | ||
|
|
||
| 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<string, RemoteJwksCache>(); | ||
|
|
||
| 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); | ||
| } | ||
|
Comment on lines
+34
to
+47
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Evict unused remote-cache namespaces. Every distinct scope remains in Add a bounded LRU policy or idle-entry eviction that removes expired cache namespaces. Also applies to: 147-147, 235-240 🤖 Prompt for AI Agents |
||
| 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]; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MEDIUM] Local PEM ( The remote cache is now correctly scoped by — Comment generated with Claude with @dominic-clerk's supervision |
||
|
|
||
| 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<JsonWebKey> { | ||
| 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; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: clerk/javascript
Length of output: 7239
🏁 Script executed:
Repository: clerk/javascript
Length of output: 50372
🏁 Script executed:
Repository: clerk/javascript
Length of output: 10885
🏁 Script executed:
Repository: clerk/javascript
Length of output: 343
Add cache-isolation tests for
apiUrlandapiVersion.The existing
apiUrltest bypasses the cache withskipJwksCache: true, and noapiVersiontest exists. For each field, keepsecretKeyandkidunchanged, then assert that the new scope performs an independent fetch and returns its own key.🤖 Prompt for AI Agents
Source: Coding guidelines