Use HS256 when one service signs and the same service verifies; use RS256, ES256, or EdDSA when the signer and the verifiers are different parties. That single fact decides the algorithm, and getting it backwards is how "algorithm confusion" attacks — forging a JWT by tricking a verifier into treating a public key as an HMAC secret — have kept working for a decade despite being public knowledge since 2015.
The three families aren't three settings on one dial
alg in a JWT header looks like a config value, but it selects between two structurally different trust models:
- HMAC (HS256, HS384, HS512) is symmetric. One secret both signs and verifies. Anyone who can verify a token can also forge one, because verifying is recomputing the same HMAC. This is fine when signer and verifier are the same process, or share a secret over a channel you fully control — think a single backend minting its own session tokens.
- RSA (RS256/384/512, and RSASSA-PSS as PS256/384/512) and ECDSA (ES256/384/512) are asymmetric. A private key signs; a public key verifies. Anyone can verify — that's the point — but only the private key holder can produce a valid signature. This is the shape you want the moment more than one party needs to check a token: multiple microservices, a mobile client, a third-party API consumer.
- EdDSA (Ed25519) is also asymmetric, using the same trapdoor-function idea as ECDSA but a different curve (Curve25519) and a deterministic signing scheme that removes ECDSA's dependency on a high-quality random nonce per signature — see RFC 6979 for why a reused ECDSA nonce leaks the private key outright. If you're picking a modern asymmetric default today, EdDSA is usually it.
RS256 vs ES256 is the smaller decision — see our RSA vs ECC piece for the underlying math — because both give you the asymmetric property that actually matters here. The decision that bites people is symmetric vs. asymmetric, made without thinking about who else will ever need to verify the token.
How the algorithm actually gets chosen (and abused)
A JWT's header carries its own alg value — {"alg":"RS256","typ":"JWT"} — and naive verification code does the obviously convenient thing: read alg from the token, then call the matching verify function with whatever key material the caller configured. That convenience is the entire vulnerability.
Here's the attack Tim McLean first disclosed against multiple JWT libraries in 2015, still called "alg confusion" or the "RS256-to-HS256 downgrade":
| Step | What happens |
|---|---|
| 1. Reconnaissance | Attacker observes the service verifies JWTs signed with RS256, and that its RSA public key is available — often published at a /.well-known/jwks.json endpoint, or just embedded in a mobile app binary |
| 2. Forge a header | Attacker builds a new token with "alg":"HS256" instead of "alg":"RS256", keeping whatever claims they want ({"sub":"admin","role":"admin"}) |
| 3. Sign with the "secret" | Attacker computes HMAC-SHA256(rsaPublicKeyPEM, header + "." + payload) — using the RSA public key's PEM text as the HMAC key |
| 4. Vulnerable verify call | Server code does something like jwt.verify(token, publicKey) with no algorithm restriction. The library reads alg: "HS256" from the attacker's header and treats publicKey as an HMAC secret, because that's the parameter the caller handed it |
| 5. Signature matches | The server computed the exact same HMAC the attacker did, using the exact same public key — a value that was never secret in the first place |
The forged token passes verification and the attacker controls every claim in it. Nothing about the RSA private key was broken; the vulnerability is entirely in a verifier that lets the token's own header pick the verification algorithm.
The fix isn't a smarter library, it's a smaller allowlist: the verifier must decide in advance which algorithm(s) it accepts, and reject anything else before touching signature bytes. RFC 8725 (JWT Best Current Practices) states this directly — implementations "MUST NOT" use the algorithm from an untrusted
algheader to select the verification algorithm.
Every mainstream JWT library shipped a fix for the specific PEM-as-HMAC-key case years ago (typically by requiring you to pass an explicit list of allowed algorithms), but "pin the algorithm server-side, never trust the header" remains something you have to opt into correctly — a verify(token, key) call with no algorithms: [...] option is still a footgun in several libraries' APIs today, and a custom-rolled verifier can reintroduce the bug from scratch.
Common mistakes
Trusting alg from the token you're verifying
Covered above, but worth stating as a rule: the accepted algorithm belongs in your service config, not the token. Always call your library's verify function with an explicit algorithms allowlist (["RS256"], not the empty default that accepts anything the library supports).
Reusing one key pair across signing and encryption
An RSA key generated for signing (RS256) and one generated for encryption (RSA-OAEP) should not be the same key pair — using a signing key to also decrypt, or vice versa, opens a separate class of cross-protocol attacks that RFC 8725 also calls out. Generate distinct keys per purpose.
Accepting alg: "none"
The JWA spec (RFC 7518) defines "none" as a legal value for genuinely unsecured JWTs — a real use case for internal contexts where the transport itself is already trusted. A verifier that doesn't explicitly exclude it from its allowlist will accept a token with an empty signature and every claim intact. If our JWT decoder article's one rule stuck — a decoder shows you the header and payload, it does not check anything — this is why: an alg: "none" token decodes to a perfectly normal-looking payload.
Rotating keys without a kid
Multi-service asymmetric verification needs a way to know which public key a given token was signed with, especially mid-rotation. The kid (Key ID) header claim exists for this — pair it with a JWKS endpoint keyed by kid, not a jku URL pulled from the token itself (fetching keys from an attacker-suppliable URL is its own SSRF-adjacent vector).
Picking RS256 by default out of habit
RS256 is the most-copy-pasted example in JWT library docs, which makes it the default far more often than its properties actually justify. If you're issuing tokens from a single backend that also verifies them, HS256 is simpler and has no key-distribution problem to get wrong. If multiple parties verify, ES256 or EdDSA give you the same asymmetric guarantee as RS256 with smaller tokens and faster verification — see the size comparison in the RSA vs ECC piece.
Which one for a new system
| Situation | Pick |
|---|---|
| One backend issues and verifies its own session/auth tokens | HS256 — generate a long random secret (32+ bytes), never a password or anything guessable |
| Multiple internal services verify tokens issued by an auth service | RS256 or ES256 with a JWKS endpoint; ES256 if you control every verifier and can require modern library support |
| Public API, third-party consumers, or a mobile client that ships a public key | RS256 for maximum library compatibility, or ES256/EdDSA if your ecosystem's clients are known to support it |
| Building something new with no legacy constraint | EdDSA (Ed25519) — deterministic signatures, no nonce-reuse failure mode, smaller than RSA |
Whichever you land on, the verifier-side rule doesn't change: pin the expected alg (or small allowlist) in your service's own configuration, and reject any token whose header claims something else — before you ever pass its signature to a crypto function.
If you want to see this in practice rather than take it on faith, our JWT encoder and JWT verifier tools sign and check HMAC-family tokens entirely in your browser — build an HS256 token, then edit its header to HS384 or corrupt the payload and watch verification fail, without ever sending the token anywhere.
