My Auth Middleware Downloaded the Same Keys on Every Request: Caching a JWKS Key Set in Node

· JavaScript & Web · By Zeeshan Ahmad

Our API checks every WorkOS access token against WorkOS's public keys. Those keys are published as a JSON Web Key Set (JWKS) at a URL built from the client ID. To verify a token, the server needs the matching key, so it has to fetch the set first.

The problem was that we fetched it on every request.

#What the code did

The middleware called a getJWKS() helper, and that helper called createRemoteJWKSet(...) from jose each time. createRemoteJWKSet returns a function that downloads the keys and caches them in memory. But because we built a new key set object for every request, the cache was always empty when the request started. Each request paid for a full download before it could do any real work.

The fix was to create the key set once per process and reuse it:

ts
let jwks: ReturnType<typeof createRemoteJWKSet> | undefined;

export function getJWKS() {
  jwks ??= createRemoteJWKSet(new URL(`https://api.workos.com/sso/jwks/${clientId}`));
  return jwks;
}

That is the whole change in production code. Everything else in the middleware stayed the same.

#Why it mattered more than the wasted calls

The extra downloads were wasteful, but the real risk was what happened when WorkOS slowed down.

jose has a default timeout of 5 seconds on the key download. If a download took longer than that, the request failed with 401 Unauthorized, even though the user's token was valid. A page that made five API calls could make five separate downloads, and each one was a chance to hit that timeout. Our call volume to WorkOS also grew with our own traffic, which is how a traffic spike could start to look like a rate-limit problem on their side.

We saw this on a slow network. Valid sessions kept failing with 401 until the key set was kept between requests. The local patch that proved it was never committed. The ticket that tracked the proper change was CHUCKTSF-1698.

I deliberately kept jose's 5 second timeout. A longer timeout would have hidden the symptom, and a request that hangs for 15 seconds is worse than one that fails quickly and can be retried. We would only change it if production logs showed JWKS timeouts.

#How key rotation still works

One question came up in review: if WorkOS rotates its keys, does the cached set go stale?

It does not, but the behaviour is narrower than I first described. When a token arrives signed with a key ID the cached set does not know, jose downloads the set again. jose puts a 30 second cooldown between these forced reloads. So a token signed with a brand-new key that arrives within 30 seconds of the last download gets a 401 until the cooldown ends. That is a short window, and it is jose's behaviour, not something our code needed to handle. The comment in the code now says exactly that.

#The test that matters

The test I trust most is the simplest one. Two authenticated requests in a row should cause one JWKS download, not two. A test that only checks a bad token returns 401 would pass on the old code too, because the cache is empty for that first call. The useful test sends a valid token, then a bad one, with the cache already warm. That proves the 401 still happens after the cache is in use.

The acceptance criteria for the ticket were:

  • getJWKS() returns the same key set object on every call.
  • A test shows two requests trigger one download.
  • Existing auth tests still pass, and a bad or expired token still returns 401.

#Checking it on staging

The change is back end only, so the app should look the same. The checks were:

1. Sign in and open several data-heavy pages. 2. Open several pages quickly in a few tabs, and watch for unexpected 401s. 3. Leave the app idle past the key refresh window, then use it again. 4. Sign out and sign back in. 5. Confirm an expired or missing session is still blocked.

Staging passed all five, and the server logs showed no new sign-in errors after the release.

#The takeaway

If a value is expensive to fetch and changes rarely, create it once and let the library cache it. The mistake was small: we built the cache inside the function that was supposed to use it. The result was a slow path on every request and a rare 401 that users saw as being signed out. Moving the object up one level fixed both.

Related articles

Home · All Tools · Blog