Blog
6 min read

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.

  1. Publish. Generate key N+1. Add its public key to the JWKS, but keep signing with key N. Wait at least one cache period so every verifier has seen it.
  2. Switch. Start signing new tokens with key N+1. Tokens signed with N are still valid and still verifiable, because N is still in the JWKS.
  3. Retire. Wait at least one max token lifetime (so every token signed with N has expired), then remove N from 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 random kids can't turn your API into a DoS amplifier against the auth server.
  • Pins the algorithm list. Never let the token's alg header 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:

  1. Generate a new key and publish it.
  2. Immediately remove the compromised key from the JWKS and switch signing.
  3. 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.
  4. Every token signed with the old key now fails verification; users re-authenticate via refresh tokens or login.
  5. 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:

  1. Generate an asymmetric key pair; publish a JWKS containing its public key.
  2. Update verifiers to accept both the HS256 secret and the JWKS (by alg + kid, with a strict allow-list).
  3. Switch the issuer to sign with the new key.
  4. 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