The Performance Trap
The JWKS endpoint is a public HTTP URL. If your verification middleware fetches it on every incoming API request, you introduce two catastrophic failure modes:
- Latency: Every request now has a synchronous network round-trip to the auth server before it can be processed. At 10ms per fetch and 1,000 RPS, that's 10 seconds of cumulative auth-server load per second of traffic.
- Self-inflicted DDoS: Your own services become the attacker. The auth server gets hammered and falls over, taking authentication down for your entire platform.
The Smart Caching Pattern
Production JWT libraries implement a fetch-cache-refresh pattern:
1. First request arrives with kid: "key-id-123"
→ Cache is empty → Fetch JWKS → Store all keys in memory → Verify token
2. Next 10,000 requests arrive with kid: "key-id-123"
→ Cache hit → Verify token (no network call)
3. Request arrives with kid: "key-id-456" (rotation happened)
→ Cache miss for this kid → Fetch fresh JWKS → Update cache → Verify token
4. Attacker sends 1,000 requests with random kid: "garbage-xyz"
→ Cache miss → Rate limit: only fetch JWKS once per 60 seconds
→ Reject token (key not found) without hammering auth server
Rate-Limiting Cache Refreshes
Step 4 above is critical. Without a rate limit on cache refreshes, an attacker can craft tokens with random kid values to force your service to make thousands of requests to the auth server per second — a cache-busting DDoS attack.
The defense: track the last time you fetched the JWKS. If a kid is not found in cache, only trigger a refresh if the last refresh was more than N seconds ago (typically 60 seconds). If the key still isn't found after a fresh fetch, reject the token.
Library Support
Most mature JWT libraries handle this automatically:
| Library | Language | JWKS Caching |
|---|---|---|
jose |
Node.js | Built-in with createRemoteJWKSet() |
python-jose |
Python | Manual caching required |
nimbus-jose-jwt |
Java | Built-in RemoteJWKSet with caching |
golang-jwt + jwks-go |
Go | Via keyfunc package |
Key Point: Never implement raw JWKS fetching without caching. Always use a library that implements refresh-on-miss with rate limiting, or implement those controls explicitly yourself.