JWKS and Signing Key Rotation for JWTs
If your JWTs are signed with one secret that never changes, a single leak lasts forever. How asymmetric signing and JWKS endpoints work, the kid header, a zero-downtime rotation schedule, caching and refetch rules for verifiers, emergency revocation, and the verification mistakes that turn JWKS into an attack vector.
Most apps start signing JWTs with a single shared secret in an environment variable — JWT_SECRET=... and HS256. It works, until you need to change that secret: every outstanding token instantly becomes invalid, everyone is logged out, and every service that verifies tokens must be updated at the same moment. So nobody rotates it, and a secret that leaked two years ago still mints valid tokens today. (What is a JWT)
The fix has two parts: asymmetric keys, so verifiers never hold anything that can sign; and JWKS, so verifiers can learn about new keys without a deploy.
Symmetric vs asymmetric signing
| HS256 (HMAC) | RS256 / ES256 / EdDSA | |
|---|---|---|
| Key | One shared secret | Private key signs, public key verifies |
| Who can mint tokens | Anyone who can verify | Only the issuer |
| Distributing to verifiers | Secret must be copied to each | Public keys can be published openly |
| Rotation | Coordinated change everywhere | Publish new public key first, then switch |
With HMAC, every service that verifies tokens can also forge them. With asymmetric signing, a compromised API server can't mint admin tokens. ES256 (ECDSA P-256) and EdDSA (Ed25519) give small signatures and fast operations; RS256 is the most widely supported.
JWKS: publishing your public keys
A JSON Web Key Set is a JSON document listing public keys, conventionally served at /.well-known/jwks.json:
{
"keys": [
{
"kty": "EC", "crv": "P-256", "alg": "ES256", "use": "sig",
"kid": "2026-09",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
},
{
"kty": "EC", "crv": "P-256", "alg": "ES256", "use": "sig",
"kid": "2026-10",
"x": "...", "y": "..."
}
]
}
Each token's header names the key that signed it:
{ "alg": "ES256", "typ": "JWT", "kid": "2026-10" }
A verifier fetches the JWKS, finds the key with the matching kid, and verifies the signature. OpenID Connect providers (Auth0, Clerk, Supabase Auth, Google, Cognito) all work this way, advertising the JWKS URL in their discovery document. (Clerk vs Auth0 vs Supabase Auth)
Zero-downtime rotation
Rotation is a three-phase dance, and the timing is driven by two numbers: verifier cache time (how long services keep a fetched JWKS) and max token lifetime.
- Publish. Generate key
N+1. Add its public key to the JWKS, but keep signing with keyN. Wait at least one cache period so every verifier has seen it. - Switch. Start signing new tokens with key
N+1. Tokens signed withNare still valid and still verifiable, becauseNis still in the JWKS. - Retire. Wait at least one max token lifetime (so every token signed with
Nhas expired), then removeNfrom the JWKS and destroy its private key.
day 0 JWKS: [N] sign: N
day 0 JWKS: [N, N+1] sign: N ← publish, wait ≥ cache TTL
day 1 JWKS: [N, N+1] sign: N+1 ← switch
day 2 JWKS: [N+1] sign: N+1 ← retire after ≥ token lifetime
If your access tokens last 15 minutes and verifiers cache JWKS for an hour, the whole rotation can complete in a couple of hours — which means you can rotate automatically, monthly or even weekly, as a non-event. Refresh tokens are usually opaque and stored server-side, so they don't constrain this. (Session vs JWT)
Store private keys in a secrets manager or KMS, never in the repo, and give the signer — not every service — access. (Secrets management beyond .env files)
Verifier rules
Most JWT libraries have JWKS support built in. With jose in Node:
import { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'))
export async function verify(token) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'api.example.com',
algorithms: ['ES256'],
})
return payload
}
What a good verifier does:
- Caches the JWKS, and on an unknown
kid, refetches once — rate-limited, so a flood of tokens with randomkids can't turn your API into a DoS amplifier against the auth server. - Pins the algorithm list. Never let the token's
algheader decide how to verify. - Checks
iss,aud,exp,nbf— a valid signature from the right key on a token meant for another service is still the wrong token. - Fails closed if the JWKS can't be fetched and nothing is cached.
The mistakes that turn JWKS into a vulnerability
Algorithm confusion. A verifier that reads alg from the token can be tricked: an attacker takes your RSA public key, signs a token with HS256 using that public key as the HMAC secret, and a naive library verifies it. Pin algorithms and keep key types bound to them. alg: none is the same class of bug.
Trusting jku / x5u / embedded jwk headers. These let a token say "verify me with the key at this URL" or "with this key I've included". If your verifier honours them, an attacker signs a token with their own key and points to it. Ignore these headers; only use your configured JWKS URL.
kid injection. If you look up keys by kid from a database or file path, a kid like ../../dev/null or ' OR 1=1 -- is attacker input. Treat it as an opaque string matched against a known set. (SQL injection explained)
Fetching the JWKS URL from user input. Multi-tenant apps that accept an issuer URL per tenant can be turned into SSRF. Allow-list issuers. (SSRF explained)
Serving JWKS over plain HTTP — anyone on the path can swap in their key. Always HTTPS. (Mutual TLS)
Emergency rotation
If a private key leaks, you can't wait for tokens to expire:
- Generate a new key and publish it.
- Immediately remove the compromised key from the JWKS and switch signing.
- Force verifiers to refresh — restart them or hit a cache-bust endpoint if you have one; otherwise they'll trust the old key until their cache expires. This is why JWKS cache times should be minutes to an hour, not days.
- Every token signed with the old key now fails verification; users re-authenticate via refresh tokens or login.
- Investigate how the key leaked, and audit tokens issued during the exposure window.
Rehearse this once. Discovering during an incident that one service hardcoded the public key in its config is a bad day.
If you're still on a shared secret
A practical migration path:
- Generate an asymmetric key pair; publish a JWKS containing its public key.
- Update verifiers to accept both the HS256 secret and the JWKS (by
alg+kid, with a strict allow-list). - Switch the issuer to sign with the new key.
- After one max token lifetime, remove HS256 support and delete the old secret.
EasySpawn gives you a server where your auth service, its JWKS endpoint and your APIs can run side by side over HTTPS — and Claude Code can help you set up jose verification and a rotation schedule. See how it works or join the waitlist.
Related: What Is a JWT? · Session vs JWT · OAuth PKCE · Secrets Management Beyond .env Files
Keep reading
WebAssembly as a Sandbox: Running Untrusted Code With Wasmtime and WASI
WebAssembly's design makes it a strong in-process sandbox: linear memory, no ambient authority, and capability-based access through WASI. How the isolation works, limiting CPU with fuel and epochs, memory limits, the component model, real uses for plugins and user code, and where it falls short.
Mutual TLS (mTLS) Explained: When the Server Checks You Too
In normal TLS only the server proves its identity. With mutual TLS the client presents a certificate too. How the mTLS handshake works, running a private CA, issuing and rotating short-lived client certificates, configuring Nginx and Node, mTLS in service meshes and webhooks, and the operational traps.