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.

JWT validation steps and the attack each one blocks Reject unlisted algorithms to stop alg-none and algorithm confusion, select the key by key id from a pinned key set to stop key injection, verify the signature, then check issuer, audience and expiry to stop cross-tenant and replay attacks. 1 · algorithm is on the allowlist else: alg none, HS256 confusion 2 · key chosen by kid from pinned JWKS else: jku or embedded jwk injection 3 · signature verifies over header.payload else: arbitrary claim tampering 4 · iss and aud match exactly else: tokens from another tenant or API 5 · exp and nbf within small skew else: indefinite reuse of a stolen token A library that "just verifies the token" without an explicit allowlist has already skipped rung one.
Five checks, five attack classes. There is no partial credit: each rung either runs or it does not.

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.

Three-phase key rotation with an overlap window The new key is published to the key set while the old key still signs, then signing switches to the new key while the old one remains published for verification, and only after the longest token lifetime plus the longest cache lifetime is the old key withdrawn. 1 · Publish new kid appears in the JWKS old key still signs 2 · Switch new key signs everything old key still verifies 3 · Withdraw old kid leaves the JWKS after the overlap window Overlap window ≥ longest token lifetime + longest verifier cache lifetime 15-minute access tokens and a 10-minute cache means at least 25 minutes, and in practice an hour. Compressing phases 1 and 2 into one step is the classic self-inflicted outage.
The window is arithmetic, not intuition: add your longest token lifetime to your longest cache lifetime and round up generously.

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

Components that must pick up a rotated key Gateways, resource services, background workers, webhook receivers and internal tools all verify tokens, and a rotation is complete only when every one of them reports the new key identifier. Gateway obvious Services obvious Workers often missed Webhooks always missed Admin tools forgotten A rotation is finished when every component reports the new kid — not when the provider says so. Emit the active key identifier as a metric from each verifier and the answer becomes a dashboard.
The right-hand boxes are where rotations break: components that verify tokens but were never on anyone's list of things that verify tokens.

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.