Skip to content

Validating JWTs with Cached JWKS in asyncio

Services that accept tokens from an identity provider verify each JWT's signature against the provider's public keys, published as a JWKS document and identified by a key ID (kid) in the token header. The cryptography is cheap; the key management is where async services go wrong. Every instance must fetch the keys, cache them, notice rotation, and do all of that without stampeding the identity provider or letting an attacker trigger fetches at will. Measured on Python 3.14 with PyJWT 2.15: verifying a token cost 17 µs with HS256, 36 µs with RS256 and 62 µs with ES256 — small enough for the event loop. With a cold key cache, 200 concurrent requests each fetched the JWKS themselves: 200 fetches. With a single-flight refresh shared by all waiters, 1 fetch, and the batch finished in 0.07 s instead of 0.22 s. Against 1,000 tokens with forged, random kid values sent in ten waves, single-flight alone still made 10 fetches — one per wave; adding a minimum refresh interval of 30 seconds made it 1, and every forged token was rejected either way. This guide builds that cache.

Prerequisites

1. Verify on the loop; it is cheap

Signature verification is a single public-key operation per token:

import jwt

claims = jwt.decode(
    token,
    key=public_key,                        # from the JWKS, selected by the token's kid
    algorithms=["RS256"],                  # an explicit allow-list, never taken from the token
    audience="api.example.com",
    issuer="https://id.example.com/",
    options={"require": ["exp", "iat", "sub"]},
)

Measured over 5,000 verifications each: 17.4 µs for HS256, 36.2 µs for RS256 and 61.7 µs for ES256. At 1,000 requests per second, RS256 verification uses about 4% of one core, so it can run on the event loop without a thread. The parameters matter more than the speed: algorithms must be a fixed allow-list (accepting whatever the token's header claims is the classic alg confusion attack), and audience, issuer and required claims must be checked, not just the signature.

Verify: a token signed with a different algorithm, audience or issuer is rejected, as is one without exp.

Cost of one jwt.decode by algorithm 3 horizontal bars comparing HS256 with the others. Cost of one jwt.decode by algorithm HS256 17.4 us RS256 36.2 us ES256 61.7 us PyJWT 2.15 with the cryptography backend, Python 3.14; signature plus claim checks. Verification is microseconds; fetching keys is where the time and the risk are.

2. Fetch the JWKS once, however many requests wait

When the cache is empty — at startup, after a restart, after a key rotation — every request that arrives needs the keys. Without coordination, each fetches them:

class JWKSCache:
    def __init__(self, session: aiohttp.ClientSession, url: str) -> None:
        self.session, self.url = session, url
        self.keys: dict[str, object] = {}
        self._refresh: asyncio.Future | None = None

    async def _fetch(self) -> None:
        async with self.session.get(self.url, timeout=aiohttp.ClientTimeout(total=5)) as r:
            r.raise_for_status()
            data = await r.json()
        self.keys = {k["kid"]: RSAAlgorithm.from_jwk(json.dumps(k)) for k in data["keys"]}

    async def refresh(self) -> None:
        if self._refresh is None or self._refresh.done():
            self._refresh = asyncio.ensure_future(self._fetch())   # one fetch in flight
        await asyncio.shield(self._refresh)                         # waiters share it

Measured with 200 concurrent requests and an identity provider that took 50 ms to answer: without single-flight, 200 JWKS fetches; with it, 1, and the requests finished in 0.07 s instead of 0.22 s. asyncio.shield keeps one cancelled waiter from cancelling the fetch everyone else is waiting on. At fleet scale the difference is the identity provider's load during a deploy: a hundred instances restarting together would otherwise send twenty thousand requests in a second. The same stampede shape, for caches in general, is in preventing cache stampedes in asyncio.

Verify: a burst of requests against a cold instance produces one JWKS request at the identity provider.

3. Rate-limit refreshes triggered by unknown key IDs

A token whose kid is not in the cache usually means a key rotation, so refreshing on an unknown kid is correct — and it also lets anyone who can send a token make your service call the identity provider. Bound it:

import time


class JWKSCache:
    MIN_REFRESH_INTERVAL = 30.0

    def __init__(self, session, url):
        ...
        self._last_refresh = -float("inf")

    async def key(self, kid: str):
        if kid in self.keys:
            return self.keys[kid]
        if time.monotonic() - self._last_refresh >= self.MIN_REFRESH_INTERVAL:
            self._last_refresh = time.monotonic()
            await self.refresh()
        if kid not in self.keys:
            raise jwt.InvalidTokenError("unknown signing key")
        return self.keys[kid]

Measured with 1,000 forged tokens carrying random kid values, in ten waves 0.1 s apart: single-flight alone made 10 JWKS fetches — each wave found the previous refresh finished and started another; with a 30-second minimum interval, 1 fetch. All 1,000 forged tokens were rejected in both cases. The interval trades a little rotation latency — a genuinely new key may take up to 30 seconds to be accepted after the first refresh — for a hard cap on what attackers can make you fetch. Identity providers publish new keys before they start signing with them, which makes that window harmless in practice.

Verify: a flood of tokens with random key IDs produces at most one JWKS fetch per interval.

JWKS fetches by cache design A grid of 2 rows by 4 columns. JWKS fetches by cache design scenario no coordination single-flight + 30 s min interval 200 requests, cold cache 200 fetches (0.22 s) 1 fetch (0.07 s) 1 fetch 1,000 forged kids, 10 waves n/a 10 fetches 1 fetch Identity provider simulated with a 50 ms response; forged tokens were all rejected.

4. Refresh proactively and tolerate provider outages

Waiting for an unknown kid to refresh means the first request after a rotation pays the fetch latency. Refreshing on a schedule moves that off the request path, and keeping the last good keys on failure keeps the service up when the identity provider is not:

async def refresh_forever(cache: JWKSCache, every: float = 600.0) -> None:
    while True:
        try:
            await cache.refresh()
            JWKS_AGE.set(0)
        except (aiohttp.ClientError, TimeoutError) as exc:
            log.warning("JWKS refresh failed, keeping %d cached keys: %r", len(cache.keys), exc)
        await asyncio.sleep(every * random.uniform(0.9, 1.1))        # jitter across instances

A background refresh every ten minutes, jittered so a fleet does not refresh in lockstep, keeps keys current without request-path fetches. On failure, the cache keeps serving the keys it has: tokens signed with known keys still validate, and only tokens signed with a brand-new key fail until the provider recovers. Start the task in the application's lifespan and cancel it at shutdown, as in managing startup and shutdown with ASGI lifespan.

Verify: with the identity provider unreachable, requests with valid tokens signed by existing keys continue to succeed.

5. Wire it into request handling

Put verification in one dependency or middleware so every route uses the same checks:

from fastapi import Depends, HTTPException, Request


async def current_user(request: Request) -> dict:
    auth = request.headers.get("authorization", "")
    if not auth.startswith("Bearer "):
        raise HTTPException(401, "missing token")
    token = auth.removeprefix("Bearer ")
    try:
        kid = jwt.get_unverified_header(token).get("kid", "")
        key = await request.app.state.jwks.key(kid)
        return jwt.decode(token, key, algorithms=["RS256"], audience=AUDIENCE, issuer=ISSUER,
                          options={"require": ["exp", "iat", "sub"]}, leeway=30)
    except jwt.PyJWTError as exc:
        raise HTTPException(401, "invalid token") from exc


@app.get("/orders")
async def orders(user: dict = Depends(current_user)):
    ...

get_unverified_header is safe to call before verification because only the kid is used, to select a key; nothing in the header is trusted for the decision. A small leeway absorbs clock skew between the identity provider and your hosts. Returning a generic "invalid token" keeps error details out of responses while logs record the specific reason. The verified claims can then be placed in a ContextVar for logging and downstream calls, as in propagating request IDs with contextvars.

Verify: every authenticated route depends on the shared verifier, and none decodes tokens itself.

How should this service handle signing keys? A decision on What is the situation with 4 outcomes. How should this service handle signing keys? What is the situation? every request verify on the loop: 36 us RS256 allow-list algorithms cold cache, many requests single-flight refresh 200 -> 1 fetch unknown kid refresh at most every 30 s forged kids: 10 -> 1 fetch steady state, provider down background refresh, keep last keys jittered The key cache, not the signature check, decides availability and abuse resistance.

Verification

JWT validation is robust when:

  • Tokens are verified on the loop with a fixed algorithm allow-list and audience, issuer and expiry checks.
  • JWKS refreshes are single-flight and shielded from cancellation.
  • Unknown-kid refreshes are rate-limited, so forged tokens cannot drive fetches.
  • Keys refresh in the background with jitter, and the last good keys survive provider outages.

Diagnostic Hook: count JWKS fetches per instance per hour and token rejections by reason (expired, bad signature, unknown key). More than a handful of fetches per hour, or a rising count of unknown-key rejections, points at either a rotation the cache is not picking up or someone probing with forged tokens.

Pitfalls & edge cases

  • Uncoordinated fetches. Measured: 200 JWKS fetches for 200 requests on a cold cache.
  • Unbounded unknown-kid refreshes. Measured: forged tokens drove one fetch per wave.
  • Algorithms from the token header. Always pass a fixed algorithms list.
  • Dropping keys on refresh failure. Keep serving the last good set.

Frequently Asked Questions

How do I cache JWKS keys in an asyncio service?

Keep the keys in memory keyed by kid, refresh with a single-flight future shared by all waiters, refresh on unknown kids at most once per interval, and run a jittered background refresh. In testing, single-flight cut 200 fetches to 1 on a cold cache.

Is JWT verification fast enough to run on the event loop?

Yes: PyJWT took about 17 µs for HS256, 36 µs for RS256 and 62 µs for ES256 per token in testing.

Can attackers abuse JWKS refresh on unknown key IDs?

Yes: each unknown kid can trigger a fetch from the identity provider. With a 30-second minimum refresh interval, 1,000 forged tokens caused one fetch in testing, and all were rejected.

What should happen if the identity provider is down?

Keep validating with the last good keys; only tokens signed with a key you have never seen will fail until the provider recovers.