CMD Guide
HomeSystem DesignAuthentication

hard JWT Signing & Key Rotation (HS256 vs RS256, JWKS)

JWT Signing & Key Rotation (HS256 vs RS256, JWKS)

A JWT is trusted because a signature over its header and payload proves it was minted by a party holding the signing key — so the entire security model reduces to who holds the key that can produce a valid signature. That single question is what separates HS256 from RS256, and it is why the "obvious" symmetric choice is dangerous the moment more than one party needs to verify tokens.

HS256: one shared secret signs and verifies

HS256 is HMAC-SHA256: signature = HMAC(secret, base64url(header) + "." + base64url(payload)). Verification recomputes that same HMAC with the same secret and compares. The consequence is structural, not a bug: the key that verifies is the key that signs. Any service you hand the secret to so it can check tokens can equally forge them — and a single leak of that secret anywhere lets an attacker mint tokens every service will accept. For a monolith that both issues and checks its own tokens this is fine and fast. For a fleet where an auth server issues tokens and ten downstream services verify them, you have now distributed a forging key to ten places.

RS256 / ES256: a key pair splits signing from verifying

Asymmetric signing (RS256 = RSA-SHA256; ES256 = ECDSA on P-256) uses a private key to sign and a separate public key to verify. The authorization server keeps the private key; every verifier gets only the public key. The public key mathematically cannot produce a valid signature, so distributing it to a thousand services creates zero forging risk. This is the whole reason RS256/ES256 is the default for multi-service and third-party token verification.

JWKS & rotating keys without downtime

Verifiers need the current public key(s) without a redeploy, so the authz server publishes them at a JWKS (JSON Web Key Set) endpoint, e.g. /.well-known/jwks.json. Each key has a kid (key id); each JWT header names the kid it was signed with. Verification is: read kid from the header → fetch that key from JWKS (cached) → verify. Rotation then works by publishing the new key alongside the old one (an overlap window) so both are verifiable, signing new tokens with the new key, and retiring the old key only after every token it signed has expired.

Traced verification of one token

  1. Token header decodes to {"alg":"RS256","kid":"2025b"}.
  2. Verifier checks alg against its pinned expected algorithm (RS256). If the token said HS256, reject immediately — do not proceed.
  3. Look up kid="2025b" in cached JWKS → the P-256 / RSA public key.
  4. Recompute the signature check over header.payload with that public key. Pass → trust the claims; fail → 401.
  5. Still validate exp, iss, aud — a valid signature is not a valid token.

Revoking a Stolen Token Right Now

Here is the drill that breaks people. A user's JWT leaks — copied from a compromised laptop, a logged Authorization header, an XSS payload. It is valid for another 15 minutes. Security asks you to kill it immediately. You reach for the revoke button and discover there isn't one. This is not an oversight; it is the definition of the thing you built. A stateless JWT is trusted purely because its signature verifies and exp hasn't passed — the verifier consults no server state. So "revoke this token" means "make a verifier reject a token that still verifies and hasn't expired," which is a contradiction unless you add back exactly the state the JWT was designed to remove. Every real answer is a point on that trade-off, so name the axis first: how fast can you kill a live token (blast-radius window) vs. how much per-request state / latency you pay to do it.

The three points on the axis

(a) Short-TTL stateless JWT + refresh rotation. Don't try to revoke the access token — outlive it. Make the access token's TTL small (5–15 min) and hand the client a long-lived refresh token it exchanges for a fresh access token. To "revoke," you revoke the refresh token at the authz server; within one access-token TTL every derived access token dies on its own exp. The access-token path stays fully stateless and fast. Cost: a leaked access token is live for the rest of its window — you shrank the blast radius, you did not eliminate it. You cannot kill a token mid-window.

(b) A revocation / deny-list cache checked per request. Keep JWTs stateless in signing, but on revoke, write the token's jti (unique id) — or the user/session id — into a fast shared store (Redis) with a TTL equal to the token's remaining life. Every verifier checks that store on every request and rejects a hit. This kills a token instantly, mid-window. Cost: you have reintroduced per-request shared state and a network lookup on the hot path — precisely the coupling and SPOF that stateless JWTs existed to avoid. The deny-list is small (only revoked tokens, and each entry self-expires), so it's cheaper than full session lookup, but the hard property — "verify touches no shared state" — is now gone.

(c) Reference / opaque tokens validated against the auth server. The token carries no claims; it is a random handle the verifier resolves via introspection on every call (expanded in the judgment layer below). Revocable by construction — delete the server record and the next lookup fails. Cost: a lookup (network or cache) on every request, not just revoked ones — the opposite trade-off to (a). You never had the stateless property to begin with, so there is nothing to lose and nothing to bolt on.

The trade-off, stated as a table

ApproachKill latency (live token)Per-request costStatelessness
(a) Short-TTL + refresh rotationUp to one access-TTL (e.g. ≤15 min)None (verify is local)Fully stateless verify
(b) Stateless JWT + deny-list cacheImmediateOne cache read per request (revoked-set only)Lost on verify path
(c) Opaque / reference tokenImmediateOne lookup per request (always)None by design

Refresh-token rotation with reuse detection

Option (a) only works if the long-lived refresh token is itself defensible — otherwise you've just moved the leak. The mechanism is rotation: every time a refresh token is redeemed, the authz server issues a new refresh token and invalidates the one just used. A refresh token is therefore strictly single-use. Now add the key insight — reuse detection: if an already-redeemed refresh token is presented again, that is a signal only theft explains. The legitimate client holds the rotated successor; a replay of the old one means two parties hold copies, i.e. it was stolen. The correct response is not to reject just that request but to revoke the entire token family (the whole rotation chain descended from that login), forcing re-authentication. This converts a silent, long-lived refresh-token leak into a self-tripping alarm: the thief's use and the victim's use collide, and you burn the family.

Trace it: login mints refresh R1. Client redeems R1 → gets access token + refresh R2; R1 is now marked spent. An attacker who copied R1 later redeems it. The server sees a spent token in family F being reused → it revokes F entirely (R1..R2), so the victim's next refresh with R2 also fails and both are forced to re-login. The window in which the attacker had access is at most one rotation interval.

Session fixation is the adjacent failure to close here: never let a session or token identifier chosen or seen before authentication survive into the authenticated session. On every successful login (and on every privilege elevation), mint a brand-new session / refresh-token family and discard any pre-auth identifier — otherwise an attacker who plants a known token value pre-login inherits the victim's authenticated session afterward.

Default recommendation

Default to (a) short-TTL access JWT (5–15 min) + rotating refresh tokens with reuse detection. It keeps the verify path stateless and fast for the overwhelmingly common case, bounds the blast radius of a leaked access token to a single short window, and turns a stolen refresh token into a detectable event rather than a silent long-term compromise. Add (b) a deny-list checked per request only for the surfaces that genuinely cannot tolerate a 15-minute window — admin/root actions, money movement, "log out everywhere" — and scope the check to those routes so you don't tax every request. Reach for (c) opaque tokens when instant, authoritative revocation is a baseline requirement across the board and you were never going to get value from stateless verification anyway (e.g. a single API gateway fronting everything, where the lookup is already local). The anti-pattern is the middle muddle: long-lived stateless access tokens (hours) and a per-request deny-list — you pay the statefulness cost on every request and still carry a large leak window. Pick the short window or pay for instant kill; don't do neither well.

Pitfall: the alg-confusion attack

The classic JWT break exploits libraries that pick the verification algorithm from the token's own alg header. An attacker takes a server that verifies with an RS256 public key — which is not secret — and crafts a token with alg:"HS256", signing it with HMAC using the public key bytes as the HMAC secret. A naive verifier sees alg:HS256, calls HMAC(publicKeyString, ...), and it matches — the attacker forged a valid token using only public information. The other classic variant is alg:"none" (no signature) being accepted. Defense: pin the expected algorithm server-side and never let the token choose it; reject none; and only allow a key of the type the pinned alg expects.

Other pitfalls

When to use which — the judgment layer

Takeaways

RS256 vs ES256: exact size and CPU trade-off

The physical signature size matters because JWTs ride in HTTP headers on every request. RS256 with a 2048-bit RSA key produces a 256-byte raw signature before base64url expansion. ES256 with P-256 ECDSA produces a 64-byte raw signature (r + s, 32 bytes each) before base64url expansion. Base64url expands both by roughly 4/3, so the RS256 signature segment is about 342 characters while ES256 is about 86 characters.

At scale that becomes egress and latency. If 50,000 req/s carry a JWT, the raw 192-byte signature difference is about 50,000 * 192 = 9.6 MB/s, before header compression effects. Across mobile networks, proxies, and logs, smaller tokens reduce bandwidth and header pressure.

The CPU trade-off is not one-dimensional. RSA verification is usually fast because the public exponent is small, but RSA signing is comparatively expensive and signatures are large. ECDSA P-256 gives much smaller signatures and usually competitive signing cost, but verification can be slower or more variable depending on library and hardware. The engineering rule: benchmark your issuer and gateway, but prefer ES256 when bandwidth/header size matters and your stack has mature ECDSA support; prefer RS256 when compatibility and very fast verification across older stacks are more important.


Sources: RFC 7515/7517/7518/7519 (JWS/JWK/JWA/JWT), Auth0 & OWASP JWT guidance, and the classic alg-confusion write-ups. Re-authored/Deepened for this guide.

🤖 Don't fully get this? Learn it with Claude

Stuck on JWT Signing & Key Rotation (HS256 vs RS256, JWKS)? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **JWT Signing & Key Rotation (HS256 vs RS256, JWKS)** (System Design) and want to truly understand it. Explain JWT Signing & Key Rotation (HS256 vs RS256, JWKS) from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **JWT Signing & Key Rotation (HS256 vs RS256, JWKS)** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **JWT Signing & Key Rotation (HS256 vs RS256, JWKS)** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **JWT Signing & Key Rotation (HS256 vs RS256, JWKS)** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes