Skip to content

Build1 publisher3 min readPublished

One rate-limited JWKS refresh on an unknown kid replaces a 15-minute failure window

A verifier picks its key from the JWT header before it checks any signature, so a rotated issuer key fails key selection. The Go sample in this walkthrough coalesces the misses into one gated fetch and keeps the old key set when that fetch errors.

The Engineer · Build desk

Illustration accompanying One rate-limited JWKS refresh on an unknown kid replaces a 15-minute failure window

What happened

  • A dev.to walkthrough on social sign-in argues that an unknown JWT kid should trigger one rate-limited JWKS refresh and a single retry, instead of a wait on the periodic cache timer.
  • The accompanying Go cache makes concurrent misses wait on a refreshing channel, so one goroutine fetches and the others re-read the key map when that channel closes.
  • A miss arriving within minInterval of the last attempt returns ErrUnknownKID immediately, with no outbound request to the issuer.
  • The post separates three outcomes: a known kid with a bad signature, an unknown kid resolved by one refresh, and an unknown kid still missing after that refresh.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • exposure Key selection happens on unverified header input, so the refresh path is reachable by anyone who can mint a token with a made-up kid. The interval gate is what keeps that from becoming a request-for-request amplifier against the issuer.
  • constraint Load on the issuer's JWKS endpoint is bounded by your minInterval, not by your login rate, so a traffic spike or a bot flood no longer changes the outbound fetch count.
  • decision Teams that alert on 401 rates need a separate signal for rotation recovery. Merging the two leaves no record to reconcile authentication events against afterwards.
  • capability A failed refresh degrades partially: sessions whose keys are already cached keep authenticating while the issuer endpoint is unreachable, and only holders of the new key are locked out.

`Lookup` takes the mutex, checks the map, and returns the key on a hit [15]. The miss path is the interesting part. If another goroutine is already fetching, the caller releases the lock, waits on the `refreshing` channel, and re-reads the map once that channel closes [16]. The wait also selects on `ctx.Done()`, so a request whose client has gone away does not sit behind somebody else's HTTP call [16].

If nobody is refreshing, one comparison decides the outcome: `time.Since(c.lastAttempt) < c.minInterval` returns `ErrUnknownKID` without touching the network [17]. A verifier reads the JWT header and selects a public key by the `kid` in it before it has checked any signature [5]. A bogus token can carry an arbitrary `kid`, and without the interval gate each such request turns into an outbound fetch to the issuer [11].

The fetch runs with the lock released [18]. On return the code re-locks and assigns `c.keys = keys` only when `err == nil`, so a failed refresh leaves the previous key set in service [19]. Tokens signed by a key already cached keep verifying through an issuer outage, while anything that needs the missing key fails [13].

Put coalescing and the interval gate together and the ceiling on issuer traffic is one fetch per `minInterval`, whatever the login rate [21]. The post calls this "an exactly-once problem in miniature": many requests find the same new `kid`, and the process should do one refresh and produce one intelligible transition in the audit trail [3].

The counter-example is a 15-minute background timer [9]. A rotation immediately after a fetch leaves almost the full 900 seconds in which new and legitimate tokens fail [9]. If rotation instants fall uniformly inside the interval, the average window is 450 seconds [22]. The post is explicit that the number is local policy and that the window exists for every positive interval [9]. It treats the timer as background maintenance, and does not lean on it to get a rotated key verified [4].

For the number to transfer to your service, the retry rule has to hold: inspect the `kid`, consult the cache, join or start a refresh on a miss, retry the lookup once, then verify, and never loop until the key appears [10]. The sample only indexes the returned JWK objects by `kid`, and the post says production verification must still hand those objects to a maintained JOSE library and enforce the expected issuer, audience, allowed algorithm, time claims, and signature [14].

An unknown `kid` still absent after one refresh is, in the post's classification, an invalid token, a wrong issuer configuration, or another condition outside the evidence the verifier has [7].

What to watch

  • A concrete minInterval value: the post treats refresh intervals as local policy, so the ceiling on issuer traffic is a number you choose.
  • Whether issuers overlap old and new signing keys long enough that a stale cache still verifies most traffic through a rotation.
  • Whether maintained JOSE libraries do the kid-triggered coalesced refresh themselves, since the post keeps the cache and the verifier as separate layers.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories