Choosing an OIDC id_token Signing Algorithm — RS256 / ES256 / HS256, and the One Line That Matters More
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.
- The OP publishes its public key at jwks_uri so that anyone can get it (this itself is the correct design)
- The attacker gets that public key
- The attacker rewrites the alg in the header from RS256 to HS256
- The attacker signs the token using the raw bytes of the public key as the HMAC key
- 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
- 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)
Related posts
Sorting OIDC / OAuth 2.0 Terms into Three Axes — Authorization Code / Implicit / Confidential / Public / PKCE
Building a Claude connector authenticated by your own OpenID Connect (OAuth2) provider
Return Login and Redirect Views by Hardcoding Social Account Provider in django-allauth (When Using AWS Cognito, etc.)
We look forward to discussing your development needs.