Validating JWTs and Rotating Signing Keys
Most JWT vulnerabilities are not broken cryptography — they are a validation step somebody skipped, or a key rotation nobody planned for. This guide is part of the OIDC and OAuth 2.0 implementation track, and it covers the two halves of the same problem: verifying a token strictly enough that forgery is impossible, and rotating the keys behind that verification without a self-inflicted outage.
Prerequisites
- A provider that publishes a key set at a JWKS endpoint, discovered as described in configuring identity providers for OIDC.
- Asymmetric signing (RS256 or ES256). If your provider only offers HS256, distributed verification is off the table and every verifier holds a key that can also mint tokens.
- A place to cache the key set with a bounded lifetime — an in-process cache is fine.
The Validation Ladder, Rung by Rung
Verification is a fixed sequence, and each rung exists because skipping it enables a specific, published attack.
The algorithm allowlist is the single most important line. Without it, a verifier that accepts HS256 will validate a token signed with the issuer’s public RSA key used as an HMAC secret — the classic confusion attack — and one that accepts alg: none will validate anything at all. Never derive the algorithm from the token; state it in your code.
Key selection matters almost as much. Choose the key by the header’s kid from a key set you fetched from a URL you control the configuration of, and reject unknown identifiers rather than trying every key in turn. A token whose header carries an embedded jwk, or a jku pointing anywhere other than your issuer, should be refused outright — those fields exist for other use cases and, honoured naively, they let the attacker supply the key that verifies their own forgery.
import { jwtVerify, createRemoteJWKSet, errors } from "jose";
const JWKS = createRemoteJWKSet(new URL(process.env.JWKS_URI!), {
cacheMaxAge: 10 * 60_000, // bounded staleness so rotation lands within minutes
cooldownDuration: 30_000, // an unknown kid cannot trigger a refetch storm
});
export async function verify(token: string) {
try {
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
issuer: process.env.ISSUER!,
audience: process.env.AUDIENCE!,
algorithms: ["RS256", "ES256"], // explicit — never read from the token
clockTolerance: "30s",
maxTokenAge: "15m", // defence in depth against an over-long exp
});
return { ok: true as const, sub: payload.sub, kid: protectedHeader.kid };
} catch (err) {
// Log the precise reason; return one generic failure to the caller.
logVerificationFailure(err instanceof errors.JOSEError ? err.code : "unknown");
return { ok: false as const };
}
}
Why Rotation Needs an Overlap Window
Rotation fails in exactly one way: the new key starts signing before every verifier knows about it, or the old key is withdrawn while tokens signed with it are still in flight. Both produce a wave of 401 responses that lasts as long as the slowest cache.
The verifier side of this contract is a bounded cache plus a rate-limited refetch when an unknown kid arrives. Together they make rotation invisible to users: a token signed with a brand-new key triggers exactly one refetch, the new key appears, and verification proceeds. Without the refetch, verification fails until the cache expires on its own; without the rate limit, a stream of tokens carrying random key identifiers becomes an outbound request flood against your provider.
Emergency Rotation Is a Different Procedure
Planned rotation is gentle. Compromise is not: when a private key may have leaked, every token signed with it must stop being accepted as quickly as possible, and the overlap window works against you.
Write the emergency procedure down before you need it. It has three steps and one decision. Publish the replacement key. Switch signing to it. Then remove the compromised key from the published set immediately, accepting that every token still signed with it will be rejected — which means every active session using those tokens re-authenticates. The decision is whether to force that immediately or to wait out the token lifetime, and it depends on whether you believe the key is being actively used to forge tokens.
Because the answer is time-critical, the mechanics should be automated and rehearsed. A key rotation that requires someone to find the right console page while an incident is running will take an hour longer than it should. Practise it in a non-production environment at least twice a year, and record how long each step actually took.
Signing Your Own Tokens
If you issue tokens as well as consume them, the same discipline applies from the other side. Keep private keys in a key-management service or hardware module rather than in application configuration, so signing is an operation the service performs rather than a secret your processes hold. Give every key a stable kid and publish the public half at a JWKS endpoint with cache headers that match your rotation cadence — a long max-age on that endpoint quietly extends every verifier’s overlap requirement.
Keep the claim set small. Every claim you add is data you have committed to keeping accurate for the token’s lifetime, and claims that change — roles, entitlements, feature flags — become stale immediately. Put a subject, an audience, an expiry and a version counter in the token, and look the rest up.
Choose ES256 over RS256 for new systems unless a consumer forces otherwise: signatures are much smaller, which matters when a token travels on every request, and verification is fast. Whichever you pick, put it in the allowlist explicitly on both sides.
Monitoring That Catches Rotation Problems Early
Three signals tell you whether the key story is healthy. The rate of signature verification failures should be near zero and flat; a step change almost always means a rotation your verifiers have not picked up. The age of the newest key in each verifier’s cache tells you whether distribution is working — a fleet where one instance is an hour behind will produce intermittent failures that are maddening to reproduce. And the count of refetches triggered by unknown key identifiers should be a small spike during a rotation and nothing in between; a steady trickle means something is sending tokens signed by an issuer you do not expect.
Alert on all three, and include the kid in your verification failure logs. Without it, the difference between “someone is probing us with forged tokens” and “our own rotation broke” is invisible.
Frequently Asked Questions
How often should signing keys be rotated?
Every 30 to 90 days for routine rotation, and immediately on any suspicion of compromise. The precise cadence matters less than the fact that the procedure is automated and rehearsed: teams that rotate rarely discover on the day of an incident that their overlap window is wrong, their caches are unbounded, or the runbook names a person who has left. Frequent, boring rotation is what makes emergency rotation survivable.
What should a verifier do with an unknown key identifier?
Refetch the key set once, rate-limited, then reject the token if the identifier is still unknown. Trying every key in the set instead is a mistake: it turns key selection into a guessing loop, hides distribution problems, and gives an attacker a way to make you burn CPU on every request. One refetch handles legitimate rotation; anything beyond that is a token you should not accept.
Is HS256 ever acceptable?
Only when a single trusted component both signs and verifies — a monolith issuing its own session tokens, for example. The moment a second service needs to verify, that service holds a key that can also mint tokens, so every verifier becomes an issuer and a compromise anywhere is a compromise everywhere. For anything distributed, asymmetric signing is what makes verification safe to hand out.
How long should the JWKS cache live?
Five to ten minutes, with a refetch on an unknown key identifier. Shorter turns your provider’s key endpoint into a hot dependency; longer extends the overlap window every rotation needs and delays recovery from an emergency withdrawal. Whatever you choose, make it explicit rather than relying on a library default, because that number is an input to your rotation arithmetic.
Should verification failures be reported differently to the client?
No — return one generic authentication failure regardless of cause, and log the precise reason server-side. Telling a caller whether the signature, the audience or the expiry failed hands an attacker a debugging tool for their forgery attempts. The detail belongs in your logs, where it is the difference between diagnosing a rotation problem and guessing at one.
Claim Validation Beyond the Basics
Issuer, audience and expiry are the minimum. Several other claims carry security meaning, and ignoring them leaves gaps that the basic checks do not cover.
The nbf claim marks the earliest moment a token is valid, and a verifier that ignores it will accept a token minted for the future — which matters when an issuer pre-generates credentials or when clock skew runs in the unfavourable direction. Check it with the same small tolerance you apply to expiry.
The jti claim is a unique identifier for the token. You do not need it for ordinary access tokens, but for one-time credentials — a password-reset token, a magic link, a token that authorises a single high-value action — recording consumed identifiers is what turns a replayable bearer string into a genuinely single-use one. Keep the record only as long as the token’s lifetime and the storage cost stays trivial.
The auth_time claim tells you when the user actually authenticated rather than when the token was issued, and it is the input to any freshness requirement. A token issued five seconds ago may represent an authentication from eight hours ago after a silent refresh, so an operation that demands recent proof of identity must read auth_time, not iat. The same reasoning drives step-up authentication for sensitive actions.
Finally, treat every claim you consume for authorization as untrusted input mapped through an allowlist. A groups or roles claim comes from a directory somebody else administers; map it into your own permission model explicitly, and drop values you do not recognise rather than passing them through.
Where Verification Belongs in Your Architecture
There is a persistent temptation to verify once at the edge and forward a trusted header inward. It saves a few microseconds per service and it removes the property that makes tokens useful: any component that receives a plain X-User-Id header must trust whatever produced it, which on an internal network is any workload that can reach it.
Verify at every trust boundary. The gateway verifies the inbound token, and each service verifies whatever credential it receives — either the original token forwarded intact, or an internal token minted by the gateway with a short lifetime and a narrow audience. Both designs work; what does not work is an unauthenticated internal assertion.
Where a service genuinely cannot verify — a legacy component, or one in a language without a maintained library — put a sidecar in front of it that does, and restrict network access so nothing else can reach the service directly. That is the same reasoning as placing enforcement points in microservices, applied to authentication rather than authorization.
Cache the key set, never the verification result keyed by the token. Caching results looks like an optimisation and is a revocation hole: a token that has been revoked or whose key has been withdrawn keeps passing until the entry expires. Verification is a signature check measured in tens of microseconds; there is nothing to optimise.
Testing Verification Properly
A verifier that accepts valid tokens is easy to write; one that rejects everything else takes deliberate testing, because the failures are silent. Build a fixture corpus once and reuse it forever.
Include a valid token as the control, then one for each way a token can be wrong: expired, not yet valid, signed by a key you do not publish, signed with alg: none, signed with HS256 using the public key as the secret, carrying the wrong audience, carrying the wrong issuer, carrying an unknown key identifier, and one with the signature truncated. Assert that the first passes and every other is rejected with a distinct internal reason. That suite takes an afternoon and it is the difference between believing your validation is strict and knowing it.
Add a rotation test that is more than a unit test: point a staging environment at a provider tenant, rotate the key, and confirm services recover inside the cache window without restarts. Teams that have never done this usually discover an unbounded cache — and they discover it during a real rotation, when every request is failing.
Finally, fuzz the header. Tokens with unexpected header parameters, deeply nested JSON, enormous key identifiers, or duplicate claims should be rejected cleanly rather than crashing the parser or consuming unbounded memory. A verifier is a parser exposed to unauthenticated input, and it deserves the scepticism you would apply to any other one.
Migrating Between Algorithms
Changing from RS256 to ES256, or introducing asymmetric signing where HS256 was used, is a rotation with an extra dimension: verifiers must accept both algorithms during the transition. Widen the allowlist first and deploy it everywhere, confirming through logs that every verifier is running the new configuration. Only then start signing with the new algorithm, and only after the overlap window remove the old algorithm from the allowlist.
The order matters because the allowlist is the control you are temporarily relaxing. Running with two algorithms accepted is fine for a defined window and dangerous as a permanent state, so put an expiry date on it and treat leaving it open as an incident of its own. Track which algorithm each verification used, so you can prove the old one has stopped appearing before you remove it.
One last operational note: keep an inventory of every component that verifies tokens, including the ones nobody thinks about — a webhook receiver, an internal admin tool, a scheduled job that calls an API on behalf of a service account. Rotation only completes when every entry on that list has picked up the new key, and the entries that break are always the ones missing from the list rather than the ones on it. Reviewing the inventory before each rotation takes minutes and removes the class of failure where one forgotten consumer starts rejecting traffic hours after everyone declared the change finished.
The Rotation Inventory at a Glance
Keep that inventory in the same repository as the rotation runbook, so updating one is an obvious prompt to update the other and neither drifts quietly out of date.
Related
- Configuring identity providers for OIDC — discovery, issuer validation and where the key set comes from.
- Secure token refresh and rotation patterns — rotating the credentials rather than the keys that sign them.
- Protecting FastAPI routes with OIDC bearer tokens — verification applied per request in a resource server.
- OAuth 2.0 token revocation best practices — what to do when a credential, rather than a key, must be withdrawn.