JWT Rotation and Revocation: Secure Token Lifecycle in Distributed Systems
JWT Rotation and Revocation keeps distributed APIs safe after a key leak. Learn short lifetimes, key ids, denylists, and refresh reuse detection in production.
JWT Rotation and Revocation matters because a signed token stays valid until it expires, even after you fire the user. When the signing key leaks, every API that trusts it will accept forgeries until you cut the key. In my experience, teams set a week-long expiry and then could not kick a stolen session. Therefore, you should keep access tokens short, name each key, and plan a revoke path before the first incident.
What a JWT is and why revoke is hard
A JWT is three parts separated by dots: a header, a payload, and a signature. The payload holds claims such as subject, issuer, audience, and expiry. RFC 7519 defines the format.
Also, the signature lets any service check the token without a central session table. That is the feature, and it is the revoke problem.
If you do not store the token, you cannot delete it. A logout click on one device does not reach every API instance. Because the token is a bearer string, anyone who copies it can replay it until expiry. Still, many apps treat the JWT as a session that should die on logout, which the format does not do by itself.
A common mistake I have seen is a signing key in the same repo as the API. After one leak, every old token and every new forgery works. If the key id never changes, you cannot tell which tokens came from the old key. Consequently, rotation and revocation are one design, not two later patches.
RFC 8725 collects the best current practice for JSON web tokens. It warns against the none algorithm and against trusting headers blindly. When you ignore those rules, a kid rotation scheme will not save you. Furthermore, the key set itself must come from a host you already trust.
Architecture and how you implement it
Give every signing key a kid, which is a key id in the header. Publish public keys in a JWKS document, as RFC 7517 describes. First, verifiers fetch that set and cache it.
Next, they pick the key that matches the token kid. Then, they check the signature, the issuer, the audience, and the expiry. Finally, they apply any revoke rule you still need.
Rotation is an overlap, not a flip. You create a new key and add it to the JWKS before you sign with it. Verifiers must accept both kids during the window.
After the last token signed by the old key has expired, you remove the old kid. Since access tokens are short, that window can be minutes or hours, not weeks.
Store private keys in secrets management for backend systems, not in the image. The signer should be a small service or a KMS operation, so app nodes never see the private key. Also, map who may call the signer through IAM roles for backend services. If every pod can sign, a stolen app token can mint new JWTs.
Revocation options that actually work
The simplest revoke is a short exp claim. Logout then means “wait a few minutes.” For many APIs, that is enough.
Specifically, a five to fifteen minute access token, in an illustrative production range, covers interactive risk without a denylist. Although some products want instant logout, measure the harm before you add shared state.
When you need instant revoke, store a denylist of jti values until those tokens expire. The check is one cache read per request. As a result, the API is no longer fully stateless.
Meanwhile, the denylist stays small because entries die with the token. Do not denylist forever, or the cache becomes a session database you forgot to design.
A third pattern is a token version on the user row. The JWT carries the version, and you bump the row to cut every token for that user. If you cache the version, keep the cache TTL very short.
Furthermore, this cuts all devices at once, which is what you want after a password change. It is a poor fit when one device should stay signed in.
Refresh tokens and reuse
Access tokens should be JWTs. Refresh tokens should be opaque values stored on the server, or rotating JWTs with reuse detection. When the client refreshes, you issue a new refresh token and invalidate the old one. If the old one appears again, someone stole it, so you revoke the whole family.
This pairs with OAuth and OIDC for backend services. The authorization server owns refresh rotation. Your API should not accept a refresh token as a bearer credential.
Before you build a custom issuer, confirm the vendor already rotates refresh tokens. After a reuse event, force the user through sign-in again.
Trade-offs among revoke styles
Each style trades latency, state, and how fast a stolen token dies. Overall, short access tokens plus refresh rotation cover most products. Add a denylist only for the actions that cannot wait.
| Style. | Extra state. | Revoke speed. | Failure mode. | When it fits. |
|---|---|---|---|---|
| Short expiry only. | None. | Wait for exp. | Stolen token lives until exp. | Most read APIs. |
| jti denylist. | Cache until exp. | Next request. | Cache down fails open or closed. | Logout and incident. |
| User token version. | One row per user. | After cache TTL. | Cuts every device. | Password change. |
| Refresh reuse detection. | Refresh family. | On next refresh. | Missed reuse if you skip rotation. | Long sessions. |
Short expiry is easy to run, and it leaves a window. A denylist is fast, and it needs a failure policy. A version column is simple, and it is blunt. Refresh rotation protects the long-lived credential, and it does not by itself kill an access token already in flight.
Do not put a denylist in front of a token that lives for days. The list will grow without bound. First, cut access token life.
Next, publish kids in a JWKS. Then, add refresh rotation. Finally, add a denylist for admin actions and for confirmed theft.
Pitfalls and failure modes
Key distribution fails more often than the crypto. Verifiers cache an old JWKS and reject the new kid. Or they cache too long and still trust a key you meant to kill.
While a region lags, some nodes accept a retired kid and others do not. If you deploy the signer before the verifiers can see the new key, logins fail.
- Publish the new public key before you sign with it.
- Keep the old kid until tokens signed with it expire.
- Reject alg none and reject keys embedded in the token.
- Check issuer and audience, not only the signature.
- Bound every denylist entry by the token exp.
- Detect refresh reuse and revoke the family.
Clock skew causes good tokens to fail. Therefore, allow a small skew and alert when rejects spike. In an illustrative production range, one or two minutes of skew is enough.
Larger skew makes expiry meaningless. Also, NTP drift on a new cluster is a classic first-day outage.
A shared JWKS across unrelated APIs is a mix-up risk. A token for one audience must not pass on another. Consequently, pin the audience in each service. We once hit a bottleneck when a global “valid jti” cache ignored audience and grew until the cache cluster evicted live denylist rows.
Private keys at rest need the same care as the rotation plan. Read encryption at rest for signing keys so a disk copy is not a key leak. In addition, send tokens only over TLS for backend engineers. A perfect kid scheme does not help if a proxy logs the bearer header.
A practical verify path
The checker below refuses the none algorithm and a missing kid. It loads the key only from your cached JWKS. When the kid is unknown, it refreshes the JWKS once and tries again. Thus, a rotation does not require a restart, and a random kid cannot force endless fetches.
def verify(token, cache):
header = decode_header(token)
if header["alg"] in ("none", ""):
raise AuthError("bad alg")
kid = header["kid"]
key = cache.key_for(kid)
if key is None:
cache.refresh_once()
key = cache.key_for(kid)
if key is None:
raise AuthError("unknown kid")
claims = check_signature(token, key, header["alg"])
if claims["iss"] != EXPECTED_ISS:
raise AuthError("iss")
if EXPECTED_AUD not in as_list(claims["aud"]):
raise AuthError("aud")
if claims["exp"] <= now_with_skew():
raise AuthError("exp")
if cache.denied(claims["jti"]):
raise AuthError("revoked")
return claimsI used an escaped comparison so the page stays plain HTML. The idea is the same in any language. If the denylist is down, decide in advance whether you fail open or closed.
For payment and admin routes, fail closed. For a public read that already has a short exp, you might fail open and page someone.
Also, do not log the full token. Since the token is the secret, a debug line is a leak. Before you ship, test three cases: a retired kid, a future kid, and a reused refresh token. After those pass, the rotation drill is safe to run in prod.
Performance, scale, and cost
Local signature checks are cheap compared with a database session read. If you add a denylist, that read must be a cache hit almost every time. Therefore, keep the denylist in memory near the API, with a short replication lag. If every check crosses a region, you gave back the latency you saved by using JWTs.
JWKS fetches should be rare. Cache the set for minutes, refresh on a timer, and refresh once on an unknown kid. Consequently, a blip at the key host does not take down the API.
Meanwhile, a single-flight lock stops a stampede when the cache expires. In an illustrative production range, a refresh every few minutes is enough if you also refresh on a miss.
Token size costs money at high QPS. Extra claims, huge scopes, and nested JSON make every request heavier. Specifically, drop profile data the API does not use. As a result, you keep the header small and the logs cleaner.
Cost of an incident dominates the cost of a short TTL. A week-long token looks efficient until one leak forces a global key cut and a user-wide logout. However, a very short TTL increases load on the issuer. Measure the refresh rate before you set exp to one minute for every route.
Key Takeaways
- Treat access tokens as short-lived, not as sessions.
- Sign with a kid and publish keys in a JWKS before the cut.
- Remove the old kid only after its tokens expire.
- Keep private keys in a vault or a KMS, not in the app image.
- Use refresh rotation and revoke the family on reuse.
- Add a jti denylist only when waiting for exp is too slow.
- Check issuer, audience, and algorithm on every verify.
FAQ
Can we revoke a JWT without shared state?
Only by waiting for expiry, or by rotating a key and waiting for tokens signed with the old key to die. When that window is too long, you need a denylist or a version check. Also, shortening exp is the change that makes both options cheap.
Should verifiers trust a key URL inside the token?
No. A token that names its own key host can point at an attacker. Therefore, pin the JWKS URL in config. If the kid is unknown, refresh only that pinned URL.
How do we handle a leaked signing key?
Publish a new key, sign only with the new kid, and remove the leaked kid as fast as you can. If access tokens are short, the wait is short. If they are long, you must denylist or reject the old kid and force a new sign-in. After the cut, confirm no service still has the old private key on disk.
Is logout the same as revocation?
Logout should delete the local session and the refresh token. The access token may still work until exp unless you denylist its jti. Since that window is the product decision, write it down. Users should know that a public computer needs the short window, not a wish.
Set an access token lifetime you could stand to wait out during a leak. Then add kid-based rotation with an overlap, and store private keys outside the app. Next, turn on refresh reuse detection. Finally, run a game day that retires a kid and confirms old tokens die on schedule.
Last updated on 08 September 2026.
[…] the same care as passwords. If you mint JWTs from a signing key, store that key here and follow JWT rotation and revocation when you cut versions. Also, OAuth client secrets belong in the same store. Read OAuth and OIDC for […]