Choosing an OIDC id_token Signing Algorithm — RS256 / ES256 / HS256, and the One Line That Matters More
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.
- The OP publishes its public key at jwks_uri for anyone to fetch (this is correct by design)
- The attacker fetches that public key
- Rewrites the header alg from RS256 to HS256
- Signs the token using the raw bytes of the public key as the HMAC key
- 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
- RFC 7518 — JSON Web Algorithms (JWA)
- RFC 8037 — CFRG ECDH and Signatures in JOSE
- RFC 8812 — CBOR/JOSE Registrations for WebAuthn Algorithms (ES256K)
- RFC 9864 — Fully-Specified Algorithms for JOSE and COSE
- OpenID Connect Core 1.0 §15.1 (Mandatory to Implement Features)
We look forward to discussing your development needs.