Removing a signing key leaves a cached copy

A local clearance-verifier experiment separates published-key removal, cached keys, early refresh and a failed JWKS fetch.

6 min read
RSS

Heretic's Node clearance verifier accepted a token after our test JWKS endpoint stopped publishing its signing key. It accepted the same token at exactly 300,000 milliseconds after caching the key. At 300,001 milliseconds it fetched the changed key set and returned unknown-kid.

A second sequence rejected the old token much sooner. A request naming a new key triggered a refresh after one second, replacing the cached set. The old token then failed without another HTTP request.

These are local reference-verifier results with generated Ed25519 keys and an explicitly supplied clock. They explain when an integrator's cached verification key stops being usable. They do not measure production rotation propagation.

The removal experiment

We used the actual Node reference verifier, two generated signing keys and three independent loopback JWKS endpoints. Each endpoint initially published key A. A signed token using A warmed its verifier cache. We then replaced the endpoint's published set with key B.

Each sequence used a separate endpoint so its cache started empty. All calls were sequential, with no refresh already in flight. The tokens remained within their expiration windows throughout the experiment. Calls supplied now = start + offset; five simulated minutes passed without waiting five real minutes. Request counts came from the loopback HTTP servers.

The following results show the TTL sequence. The request count is the number of JWKS requests made by that verification call.

Offset from cache fill Published keys Token key Valid Reason Requests
0 ms A A true null 1
1,000 ms B A true null 0
300,000 ms B A true null 0
300,001 ms B A false unknown-kid 1

The verifier looks up a cache entry by origin and site key. Its expiration test is:

js
if (!entry || now - entry.at > TTL_MS) {
  const keys = await fetchKeys(origin, siteKey);
  if (!keys) return undefined;
  cache.set(id, entry = { at: now, keys }); loaded = true;
}

TTL_MS is 300_000. The strict > explains the exact boundary. In these sequential calls, an unexpired entry supplies A without asking the endpoint whether A still exists. A concurrent call can instead wait for an already-running refresh before looking up its key; that path is outside this experiment. Once the entry expires, a successful fetch replaces the entire cached key map with B. Lookup of A returns no key, which the verifier exposes as unknown-kid.

That one-millisecond boundary follows this implementation's condition. It is not a timing measurement or a guarantee that all deployed verifiers refresh together.

A new key can shorten the delay

A missing key ID has another refresh path. When the cache is still fresh, the verifier can fetch again if it has not already loaded keys during this call and the unknown-key refresh cooldown permits it. The cooldown is five seconds per origin and site key.

In the second sequence, a token signed with B arrived one second after the initial cache fill:

Offset Token key Valid Reason Requests
0 ms A true null 1
1,000 ms B true null 1
1,001 ms A false unknown-kid 0

Fetching B replaced the map containing A. The next A request found no matching key, and the cooldown prevented another fetch.

Key lookup happens before signature verification. A valid B signature is what made the middle row succeed; it is not a prerequisite for attempting an eligible unknown-key refresh. This experiment uses a valid token to show both successful refresh and subsequent rejection of the removed key.

The cached-key lifetime therefore depends on requests as well as elapsed time. Two processes that fetched A at the same instant can stop accepting it at different times if only one receives a request that refreshes its key set.

An unavailable key endpoint produces a different failure

For the third sequence, the endpoint returned HTTP 503 after the initial cache fill. A request using A at offset 1,000 ms still verified, with no HTTP request. At 300,001 ms, verification attempted a fetch and returned jwks.

The expired entry remained in memory, but the failed fetch did not fall back to it. This distinguishes two operational failures: unknown-kid means lookup completed without the requested key; jwks means the verifier could not obtain the required key set. Verification failed in both cases even though the token itself had not expired.

Applying the result to Heretic rotation

The fixture deliberately removed A when it published B. Heretic's current rotation contract keeps the current and previous key versions in JWKS; a second rotation retires the older version. The fixture models removal of an older published key, not the first routine rotation.

A token's own expiration and the verifier's key-cache expiration are separate checks. Removing a published key does not remotely erase copies held by application processes. The reference verifier can continue using its cached copy, refresh it early on an eligible unknown key, or refuse verification when a required refresh fails.

For an integration test, keep the token unexpired and observe both verification reason and JWKS request count. Test the cached-key case, a successful refresh after removal, and an unavailable endpoint after cache expiry separately. Supplying a jwks object directly bypasses the fetch cache, so that mode cannot reproduce this timeline.

Reproducing the timeline

The standalone reproduction package contains the unchanged Node verifier and its MIT test fixture, the matrix driver, expected results and file hashes. With Node.js 24, run node matrix.mjs > results.json from the extracted directory. Each row asserts the validity, reason and JWKS request count; an assertion failure exits nonzero. The source-package and clean-extraction runs on Node.js 24.19.0 reproduced all ten rows. Keys are generated in memory, and only loopback HTTP endpoints are contacted.

Next article A passed challenge keeps the original finding Nine signed-record cases show which address a challenge clearance binds to and which original facts survive escalation.

The probe runs on your own browser.

The public demo returns the verdict and its named findings.