Skip to content

Why a rotated signing key can be rejected for up to fifteen minutes

The JWT verifier caches the issuer's signing keys, and the cache refreshes on a timer rather than on demand. That one design choice explains most of the surprising behaviour people hit with authn in production, so it is worth understanding before you pick a RefreshInterval.

What the cache actually does

The verifier holds a map from kid to public key, plus the time the map was fetched. When a token arrives:

  1. Look up the token's kid. If it is present and the map is younger than RefreshInterval, use it. Done — no network.
  2. Otherwise consider refreshing. A refresh is skipped if the map is still younger than RefreshInterval, or if a fetch was attempted within the last 30 seconds.
  3. If a refresh happened and succeeded, look the kid up again. If it is still absent, reject the token.
  4. If the refresh failed but a cached key for that kid exists, use the cached key anyway.

Step 2 is where the surprise lives.

An unknown kid does not trigger a fetch

It is natural to assume a cache miss forces a lookup. This one does not. If the cached key set was fetched eight minutes ago and RefreshInterval is the default fifteen minutes, a token signed by a brand-new key is rejected with no JWKS key for kid "…" and no request is made to the issuer at all. The cache is still considered current, so there is nothing to refresh.

The practical consequence: after your issuer rotates its signing key, tokens signed with the new key fail for up to RefreshInterval before anything self-heals. Nothing is broken and no intervention is needed — but if you are watching a dashboard during a rotation, that is what you are seeing, and it will clear on its own.

The package's own test suite pins this behaviour, so it is intended rather than accidental:

A miss while fresh does not refetch.

Why it is built that way

The alternative — refresh whenever a kid is unknown — turns an unauthenticated request into a request to your identity provider. Anyone who can reach your endpoint can then mint junk tokens with random kid values and use your service to hammer the issuer, which is both a denial-of-service amplifier against a dependency you do not own and a way to get your own service rate-limited by it.

Trading a bounded window of rejection for an unbounded fetch rate is the safer default when the failure mode is "some tokens are rejected briefly" rather than "the issuer falls over".

The 30-second floor between fetch attempts covers the same concern once the cache has gone stale: if the issuer is down, a stream of requests retries at most twice a minute rather than once per request.

Choosing a RefreshInterval

The interval is a straight trade between rotation latency and load on the issuer.

  • Shorter — a rotated key is picked up sooner, and the worst-case window of rejected tokens shrinks to roughly the interval. You fetch the JWKS more often.
  • Longer — fewer fetches, longer outage window during a rotation.

The default of fifteen minutes suits an issuer that rotates on a schedule measured in days and publishes the new key well before signing with it. If your issuer rotates without overlap, or you cannot predict when, a value in the one-to-two minute range costs very little: the fetch is one small HTTPS request, and it only happens when a request actually arrives after the interval has elapsed.

Setting it to zero does not disable caching — any value less than or equal to zero is replaced by the default fifteen minutes.

What a good issuer does, and what to do when yours does not

An issuer that overlaps keys — publishing the new public key in its JWKS some time before it starts signing with it, and keeping the old one for a while after — makes the window irrelevant, because the new kid is already in a cache fetched before the switch. Most managed identity providers do this.

If yours does not, your options are to shorten RefreshInterval, or to restart the service after a rotation — construction primes the cache, so a fresh process always starts with current keys.

Failure is biased towards staying up

Step 4 above is deliberate: if the JWKS endpoint is unreachable and the cache holds a key for the requested kid, that key is used even though the cache is stale. An identity provider outage therefore does not immediately take your API down with it. The bound on this is the key's own lifetime, which authn cannot see — a key withdrawn by the issuer will keep verifying tokens here until a successful fetch replaces the cached set.

That is a considered trade rather than an oversight, and it is the one place where authn prefers availability over strictness. If your threat model cannot accept it, the mitigation is a short RefreshInterval and alerting on the operational error branch — a verify error that does not wrap ErrUnauthenticated is a JWKS problem, not a credential problem.

Duplicate kid values break this

The cache is a map, so if a JWKS publishes two keys under the same kid, only the last one survives the fetch and tokens signed by the other are rejected with token signature is invalid. An issuer that reuses a kid across a rotation defeats the overlap that would otherwise make rotation seamless. There is nothing to configure here — the fix belongs at the issuer.