Choosing an OIDC id_token Signing Algorithm — RS256 / ES256 / HS256, and the One Line That Matters More

2026-08-28 16:02 (44 days ago)
Paper Bouncer
Play a song themed on this article

I run my own OpenID Connect provider, and I looked again into which signing algorithm to use for the id_token. Often only two are known, RS256 and HS256, but JWA (RFC 7518) defines several others.

The conclusion first: use RS256 or ES256. Avoid HS256. But while looking into it, I came to feel that "whether you pin the alg you accept" is far more important than which one you choose.

What you can choose from

alg Name Type Key Signature size
HS256/384/512 HMAC with SHA-2 Symmetric Shared secret 32/48/64 B
RS256/384/512 RSASSA-PKCS1-v1_5 Asymmetric RSA 2048bit or more 256 B (at 2048bit)
PS256/384/512 RSASSA-PSS Asymmetric RSA 2048bit or more 256 B
ES256 ECDSA P-256 + SHA-256 Asymmetric EC P-256 64 B
ES384 / ES512 ECDSA P-384 / P-521 Asymmetric EC 96 / 132 B
ES256K ECDSA secp256k1 (RFC 8812) Asymmetric secp256k1 64 B
Ed25519 / Ed448 EdDSA (RFC 8037 → RFC 9864) Asymmetric Ed25519 etc. 64 B
none No signature — — 0

For EdDSA, RFC 9864 (Fully-Specified Algorithms for JOSE and COSE) came out in 2025. The identifier EdDSA alone could not tell whether it meant Ed25519 or Ed448, and this was causing actual problems in OIDC Discovery and WebAuthn negotiation, so it moved to the identifiers Ed25519 / Ed448, which specify the curve. The old EdDSA is now Deprecated.

Note that encrypting the id_token (id_token_encrypted_response_alg) uses a separate family from JWE (RSA-OAEP / ECDH-ES / A128KW and so on), and should be considered separately from signing.

The biggest difference is symmetric vs. asymmetric

HS256 (symmetric) RS256 / ES256 (asymmetric)
Key The OP and the RP share the same key The OP has the private key, and the RP gets the public key from jwks_uri
Forgery Anyone who can verify can also forge The RP can only verify
Multiple RPs A different key for each RP. The key cannot be published One public key pair covers all RPs
SPA / mobile Cannot be used, because they cannot hold a secret Can be used
Key rotation Coordination with every RP on each reissue Just replace the JWKS, with a kid
Key strength The entropy of the shared secret The strength of RSA 2048 / P-256

In practice, the last row has the biggest impact.

When you use HS256 in OIDC, the HMAC key is the client_secret itself. RFC 7518 says the HMAC key must be at least as long as the hash output (256 bits or more for HS256), but a real client_secret is sometimes a short string issued on an admin screen, or a string a human decided.

In that case, if you capture one JWT, you can brute-force the secret offline. hashcat has a mode for JWT (-m 16500), and a dictionary attack works as is. These are not attempts over the network, so rate limiting has no effect either.

It is not that the HS256 algorithm is weak. HMAC-SHA256 itself is solid. What is weak is the practice of using the client_secret as its key.

Algorithm strength is not a selection criterion

This surprised me, but the cryptographic strength of the algorithm itself is not a main factor in the choice.

HS256 / RS256 (2048bit) / ES256 / Ed25519 are all at a level that "cannot be broken" in practice. Strictly speaking, RSA-2048 is equivalent to about 112 bits, the lowest of these. NIST accepts it until 2030 and recommends 3072 bits or more after that. P-256 and Ed25519 are equivalent to 128 bits. The collision resistance of SHA-256 is 128 bits, but what matters in signature verification is second-preimage resistance, so it is not a problem.

So the question "is ES256 safer than RS256" has almost no meaning. The reasons for choosing are ease of key management and token size, not strength.

The real threat is negotiation

JWT implementation vulnerabilities are concentrated in how the header is interpreted, not in the cryptography itself.

alg: none

Set the header to {"alg":"none"} and make the signature part empty. It passes if the verifier is implemented to simply "treat it as unsigned, as the header says".

alg confusion (RS256 → HS256)

This is the most famous attack on JWT, and it is the most important one.

  1. The OP publishes its public key at jwks_uri so that anyone can get it (this itself is the correct design)
  2. The attacker gets that public key
  3. The attacker rewrites the alg in the header from RS256 to HS256
  4. The attacker signs the token using the raw bytes of the public key as the HMAC key
  5. If the verifier is implemented to "look at the alg in the header and verify with the configured key", it recomputes the MAC as HS256 with the same public key, and it matches

A published value passes as the signing key as is. The root cause is deciding how to verify by looking at a header value that the attacker can control.

jku / x5u injection

The JWS header has fields where you can write the URL to get the key from. If the verifier simply fetches it, it is made to verify with a JWKS the attacker prepared. A decent library does not follow URLs that come from the header.

ECDSA nonce reuse

ECDSA uses a random number k for each signature. If it is repeated twice, the private key can be recovered. This is the incident in which the PS3 signing key leaked. You can avoid it by using deterministic ECDSA from RFC 6979. EdDSA is deterministic by design in the first place, so it does not have this problem. This is one of the implementation strengths of EdDSA.

The fix is simply pinning the expected alg

The alg: none and alg confusion attacks above can be prevented with the same one line. Do not trust the alg written in the token header, and allow only the alg you expect.

In OIDC, id_token_signed_response_alg in the client registration metadata lets you declare "this RP accepts only this alg". Reflect that on the verifying side as well.

// ASP.NET Core (Microsoft.IdentityModel)
o.TokenValidationParameters.ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 };  // "RS256"
# PyJWT: algorithms is a required argument, so it structurally cannot be forgotten
jwt.decode(token, key, algorithms=["RS256"])
// node-jsonwebtoken: omitting this is dangerous. Always be explicit
jwt.verify(token, key, { algorithms: ['RS256'] })

When you choose a library, check whether specifying the expected algorithm is a required argument. An API that lets you omit it becomes vulnerable as soon as it is omitted.

Selection table

alg Verdict
RS256 The lowest common denominator for interoperability. It is the only signing algorithm that OIDC Core requires OPs to implement, so you can always use it. If unsure, pick this
ES256 The modern first choice. The signature is 64 B, 1/4 of RSA-2048, so the token is small. The key is small too, and signing is fast
PS256 RSASSA-PSS is theoretically stronger than PKCS#1 v1.5 (it has a security proof). But some implementations do not support it. The difference is not large enough to strongly require it
Ed25519 Cryptographically the best. It is deterministic, so the nonce problem does not occur, and it is fast and small. But in some languages and runtimes it is not in the standard library (.NET does not support it in the BCL and needs BouncyCastle)
HS256 Avoid. The signature strength drops to the entropy of the client_secret. Key rotation involves coordination with every RP. Cannot be used with public clients
none Out of the question

Signature verification is faster with RSA (public exponent 65537) than with ECDSA. The RP only verifies, so this is a reason to choose RS256 and not a weakness. The reasons to choose ES256 are token size and key management, not performance.

References

Categories

Archive