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:
- Look up the token's
kid. If it is present and the map is younger thanRefreshInterval, use it. Done — no network. - 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. - If a refresh happened and succeeded, look the
kidup again. If it is still absent, reject the token. - If the refresh failed but a cached key for that
kidexists, 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.