# 05 — HS256 vs RS256 [← CSRF vs permitAll](04-csrf-permitall-403.md) · [next: SecurityContext →](06-securitycontext-and-statelessness.md) ## The distinction that matters | | HS256 | RS256 | |---|---|---| | key | one shared secret | private/public pair | | who can **verify** | anyone who can sign | anyone at all | | who can **sign** | anyone who can verify | only the private-key holder | | signature size | 32 bytes | 256 bytes (RSA-2048) | | sign cost | ~microseconds | ~100× HMAC | | verify cost | ~microseconds | ~10× HMAC | | key distribution | copy the secret everywhere | publish a JWKS URL | The performance column is not the deciding one. **The deciding question is whether the set of services that verify tokens is the same as the set you trust to mint them.** With HS256 the answer is forced: verifying requires the signing secret, so every verifier is also an issuer. One compromised read-only reporting service can mint an admin token. If the answer is "no", you need RS256 (or ES256), and no amount of secret rotation substitutes. ## HS256 ```java this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); @Bean JwtEncoder jwtEncoder() { return NimbusJwtEncoder.withSecretKey(this.secretKey) .algorithm(MacAlgorithm.HS256) .build(); } @Bean JwtDecoder jwtDecoder() { return NimbusJwtDecoder.withSecretKey(this.secretKey) .macAlgorithm(MacAlgorithm.HS256) .build(); } ``` Three things to notice. **The builder method is `algorithm(..)`, not `jwsAlgorithm(..)`.** `NimbusJwtEncoder`'s `SecretKeyJwtEncoderBuilder` (added in Spring Security 7.0) exposes exactly two methods: `algorithm(MacAlgorithm)` and `jwkPostProcessor(Consumer)`. The decoder side, confusingly, *does* use `macAlgorithm(..)` / `signatureAlgorithm(..)`. **The secret must be ≥ 256 bits.** Nimbus enforces the JWA rule that an HMAC key is at least as long as its digest; a shorter one throws `KeyLengthException` at encoder construction, not at first request. [`Hs256KeyConfig`](../src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java) fails fast with a clearer message. A short secret is also brute-forceable offline — the attacker has the ciphertext, the plaintext, and unlimited attempts. **A passphrase is not a key.** `"changeit-changeit-changeit-change"` is 32 bytes and passes the length check while having perhaps 40 bits of entropy. Generate it: ```bash openssl rand -base64 48 ``` ## RS256 ```java @Bean JwtEncoder jwtEncoder() { return NimbusJwtEncoder.withKeyPair(this.publicKey, this.privateKey) .algorithm(SignatureAlgorithm.RS256) .jwkPostProcessor(jwk -> jwk.keyID("demo-rsa-2026-08")) .build(); } @Bean JwtDecoder jwtDecoder() { return NimbusJwtDecoder.withPublicKey(this.publicKey) .signatureAlgorithm(SignatureAlgorithm.RS256) .build(); } ``` There is **no `keyId(..)` method** on the builder. The `kid` is set by post-processing the Nimbus JWK builder — `jwkPostProcessor(jwk -> jwk.keyID(...))`. Without a `kid`, key rotation is impossible: the verifier cannot tell which of two published keys to try. ### Publishing the public half [`Rs256KeyConfig.JwkSetEndpoint`](../src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java) serves a real JWK Set. From [`rs256-demo.txt`](output/rs256-demo.txt): ```json { "keys": [ { "kty": "RSA", "e": "AQAB", "kid": "demo-rsa-2026-08", "n": "5NEDQPQW0Gz6iR5-UNl7J7660_Psd5q1f5VamK9KTS9f6YhPPIG8mfi6zWe8Xmxx..." } ] } ``` `n` and `e` only — the public modulus and exponent. A private key would additionally carry `d`, `p`, `q`. **Audit for those letters** before exposing a JWKS endpoint: leaking `d` hands over the signing key. A separate resource server then needs no key material at all: ```java @Bean JwtDecoder jwtDecoder() { return NimbusJwtDecoder.withJwkSetUri("https://issuer.example.com/.well-known/jwks.json") .build(); } ``` or, in `application.yaml`: ```yaml spring: security: oauth2: resourceserver: jwt: issuer-uri: https://issuer.example.com ``` `issuer-uri` fetches OIDC discovery **at startup** and fails the context if the issuer is unreachable. `jwk-set-uri` fetches lazily. In an environment where the issuer boots alongside the resource server, `issuer-uri` produces a startup-ordering dependency that `jwk-set-uri` does not. ## Rotation RS256 rotates without downtime because the verifier can hold several keys: 1. Generate a new pair with a new `kid`. 2. Publish **both** public keys in the JWK Set. 3. Wait for caches to refresh (`NimbusJwtDecoder` caches, and honours `Cache-Control`). 4. Switch the issuer to sign with the new `kid`. 5. Wait one full access-token TTL, so no live token references the old key. 6. Remove the old key from the JWK Set. HS256 has no equivalent. The secret is symmetric, so steps 2 and 4 are the same step, and every token signed with the old secret is invalid the moment you rotate. The workarounds are a decoder that tries both secrets during a window, or a hard cutover that logs everyone out. ## Algorithm confusion — pin the algorithm The classic JWT attack: take an RS256 token, change the header to `alg: HS256`, and sign it with the **public key as the HMAC secret**. A verifier that reads `alg` from the token and looks up "the key" will verify it, because the public key is public. Spring Security is not vulnerable by default — `NimbusJwtDecoder.withPublicKey(...)` defaults to RS256 and will not switch families. But pin it anyway, because the intent should be in the code rather than in a default: ```java NimbusJwtDecoder.withPublicKey(publicKey) .signatureAlgorithm(SignatureAlgorithm.RS256) .build(); ``` The related `alg: none` attack is a non-issue here — Nimbus refuses unsigned JWTs for a configured verifier — but the same principle applies: never let the token choose how it is verified. ## Which to pick **HS256** — one service issues and consumes its own tokens; the secret never leaves that deployment unit; you want the smallest tokens and the cheapest verification. A monolith. **RS256 / ES256** — more than one service verifies; a third party verifies; you need rotation without a flag day; compliance requires the signing key in an HSM or KMS. Any real microservice estate. ES256 deserves a mention: same asymmetric properties as RS256 with 64-byte signatures instead of 256, and Spring Security supports it out of the box via `NimbusJwtEncoder.withKeyPair(ECPublicKey, ECPrivateKey)`. If you are choosing today and your clients can handle EC, it is the better default.