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

2026-08-28 16:02 (67 minutes ago)

While running my own OpenID Connect provider, I went back and looked into which signing algorithm to use for the id_token. Most people only know RS256 and HS256, but JWA (RFC 7518) defines quite a few more.

The short answer: use RS256 or ES256, and avoid HS256. But what struck me while researching this is that which one you pick matters far less than whether you pin the alg you accept.

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+ 256 B (at 2048bit)
PS256/384/512 RSASSA-PSS Asymmetric RSA 2048bit+ 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 bare EdDSA identifier could not tell you whether Ed25519 or Ed448 was meant, which caused real trouble in OIDC Discovery and WebAuthn negotiation, so it moved to curve-specific identifiers Ed25519 / Ed448. The old EdDSA is now deprecated.

Encrypting the id_token (id_token_encrypted_response_alg) is a separate JWE family (RSA-OAEP / ECDH-ES / A128KW ...) and should be considered separately from signing.

The biggest difference is symmetric vs. asymmetric

HS256 (symmetric) RS256 / ES256 (asymmetric)
Key OP and RP share the same key OP holds the private key, RP fetches the public key from jwks_uri
Forgery Anyone who can verify can also forge RP can only verify
Multiple RPs A separate key per RP, and it cannot be published One public key pair serves every RP
SPA / mobile Cannot hold a secret, so unusable Usable
Key rotation Re-issuing means coordinating with every RP Just swap the JWKS with a kid
Key strength Entropy of the shared secret Strength of RSA 2048 / P-256

The last row is what matters most in practice.

With HS256 in OIDC, the HMAC key is the client_secret itself. RFC 7518 says the HMAC key should be at least as long as the hash output (256 bits for HS256), but real-world client_secrets are often short strings issued by an admin panel or chosen by a human.

Once that is the case, capturing a single JWT lets you brute-force the secret offline. hashcat has a JWT mode (-m 16500) and a dictionary attack goes straight through. It is not a network attempt, so rate limiting does not help.

The HS256 algorithm itself is not weak. HMAC-SHA256 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 the main factor.

HS256 / RS256 (2048bit) / ES256 / Ed25519 are all at a practically unbreakable level. Strictly speaking, RSA-2048 is about 112 bits of security, the lowest of the set. NIST tolerates it through 2030 and recommends 3072 bits or more after that. P-256 and Ed25519 are about 128 bits. SHA-256 has 128-bit collision resistance, but signature verification depends on second-preimage resistance, so there is no practical impact.

So the question "is ES256 safer than RS256" is almost meaningless. What should drive the choice is ease of key management and token size, not strength.

The real threat is negotiation

JWT implementation vulnerabilities cluster around header interpretation, not the cryptography.

alg: none

Set the header to {"alg":"none"} and leave the signature empty. If the verifier naively follows the header and treats the token as unsigned, it passes.

alg confusion (RS256 → HS256)

This is the most famous JWT attack, and the one that matters most.

  1. The OP publishes its public key at jwks_uri for anyone to fetch (this is correct by design)
  2. The attacker fetches that public key
  3. Rewrites the header alg from RS256 to HS256
  4. Signs the token using the raw bytes of the public key as the HMAC key
  5. If the verifier "reads alg from the header and verifies with the configured key", it recomputes the MAC as HS256 with the same public key and it matches

A publicly known value ends up working as the signing key. The root cause is deciding how to verify based on a header value the attacker controls.

jku / x5u injection

The JWS header has fields that can point to a URL for fetching keys. If the verifier fetches that URL as-is, it can be made to verify against a JWKS the attacker prepared. Decent libraries never follow URLs from the header.

ECDSA nonce reuse

ECDSA uses a random k per signature. If the same k is used twice, the private key can be recovered. This is how the PS3 signing key leaked. Deterministic ECDSA per RFC 6979 avoids it. EdDSA is deterministic by design and does not have this problem at all, which is one of its practical strengths.

The fix is simply pinning the expected alg

Both alg: none and alg confusion disappear with the same single line: never trust the alg written in the token header, and only allow the alg you expect.

In OIDC, the client registration metadata field id_token_signed_response_alg lets an RP declare "I only accept this alg". Reflect that on the verification side too.

// 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 choosing a library, check whether the expected algorithm is a required argument. An API that lets you omit it becomes vulnerable the moment someone does.

Selection table

alg Verdict
RS256 The lowest common denominator for interoperability. The only signing algorithm OIDC Core requires OPs to implement, so it always works. Pick this if unsure
ES256 The modern first choice. 64 B signatures, a quarter of RSA-2048, so tokens are small. Small keys and fast signing
PS256 RSASSA-PSS is theoretically more robust than PKCS#1 v1.5 (it has a security proof), but some implementations lack support. Not a big enough difference to insist on
Ed25519 Cryptographically the best. Deterministic, no nonce accidents, fast and small. But some languages and runtimes lack it in the standard library (.NET has no BCL support and needs BouncyCastle)
HS256 Avoid. Signature strength degrades to the entropy of client_secret. Key rotation requires coordinating with every RP. Unusable for public clients
none Out of the question

One note: signature verification is faster with RSA (public exponent 65537) than with ECDSA. RPs only verify, so that is a reason to pick RS256 but not a weakness. The reason to pick ES256 is token size and key management, not performance.

References

Please rate this article (No signup or login required)
Currently unrated
The author runs the application development company Cyberneura.
We look forward to discussing your development needs.

Categories

Archive