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
- Token header decodes to
{"alg":"RS256","kid":"2025b"}. - Verifier checks
algagainst its pinned expected algorithm (RS256). If the token saidHS256, reject immediately — do not proceed. - Look up
kid="2025b"in cached JWKS → the P-256 / RSA public key. - Recompute the signature check over
header.payloadwith that public key. Pass → trust the claims; fail → 401. - 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
| Approach | Kill latency (live token) | Per-request cost | Statelessness |
|---|---|---|---|
| (a) Short-TTL + refresh rotation | Up to one access-TTL (e.g. ≤15 min) | None (verify is local) | Fully stateless verify |
| (b) Stateless JWT + deny-list cache | Immediate | One cache read per request (revoked-set only) | Lost on verify path |
| (c) Opaque / reference token | Immediate | One 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
- No
kiddiscipline. If verification tries all keys, a retired-but-still-published key extends the life of tokens you meant to kill. Pin the key bykid. - Fetching JWKS per request ties every verify to a network hop and a SPOF. Cache with a TTL
and refresh on unknown
kid. - Symmetric secret in client-reachable code. An HS256 secret shipped to a mobile app or SPA is a forging key in the wild.
When to use which — the judgment layer
- Use HS256 when the same trust boundary both issues and verifies (a single service / monolith). It is simpler (one secret, no key management) and slightly faster to compute. Why not: it is unsafe to distribute — the moment a second party must verify, you have handed out a forging key.
- Use RS256 / ES256 when tokens cross a trust boundary: microservices, third parties, public clients, gateways. Cost: key-pair management, JWKS publication/rotation, and asymmetric verify is measurably slower than HMAC (usually negligible next to a network round-trip). ES256 over RS256 when you want much smaller keys/signatures at equal security.
- The named alternative to signed tokens entirely is opaque tokens + server-side introspection: the token carries no claims, and each verifier calls the authz server to resolve it. That buys instant revocation (nothing is trusted locally) at the cost of a network call per verification — the opposite trade-off to a self-verifying JWT.
Takeaways
- The security question for any JWT is "who can produce a valid signature." HS256 answers "everyone who can verify"; RS256/ES256 answers "only the private-key holder."
- Use HS256 only inside one trust boundary; use RS256/ES256 the instant tokens are verified by anyone else.
- JWKS +
kidgive zero-downtime rotation via an overlap window; retire the old key only after its tokens expire. - Always pin the expected
algserver-side — letting the token choose enables the alg-confusion forgery.
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.
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.
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.
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.
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.