1
0
Files
spring-auth-demo/docs/05-hs256-vs-rs256.md
Ankur Mhatre 4dc45d5e00 Add OAuth2 resource server project: JWT validation, JWKS and key rotation
Companion code for the follow-up article. The repository now holds two Maven
projects sharing one docs/ tree:

  jwt-authentication/       the hand-written filter application (unchanged, moved)
  oauth2-resource-server/   a resource server, a Keycloak compose, and a stub
                            issuer whose JWK Set can be mutated on command

The stub exists because Keycloak will not rotate a signing key at a chosen
second, report how many times its JWKS endpoint was fetched, or drop a key from
the published set on request - and the caching and rotation measurements need
all three. The Keycloak run confirms the same code path against a real issuer.

Findings captured under docs/output/, all from real runs:

  * The default validator stack does not check aud. A token minted for another
    service in the same realm is accepted.
  * Spring Security builds its JWKSource with refreshAheadCache(false) and
    rateLimited(false), overriding two of Nimbus's protective defaults, and
    enables Nimbus caching only when NO Spring cache was supplied - so
    supplying one removes the five-minute expiry.
  * A key retired from the JWK Set stops being accepted at t+300s with the
    default cache, and never with a Spring cache that has no TTL.
  * 25 tokens carrying an unknown kid produce 25 JWKS fetches at the issuer,
    through permitAll() endpoints included.
  * A hyphenated client id in an authorities-claim-expression parses as
    subtraction; the SpelEvaluationException is swallowed and logged at TRACE.
  * A clientScopes key in a Keycloak realm import replaces the built-in scopes
    rather than adding to them.

New docs chapters 12-18. README covers both projects. Existing docs and scripts
updated for the new paths; no docs/output/ file from the first article moved, so
links in the published article still resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013f7f2XZXrQ6gW3RtZE187t
2026-08-23 11:00:56 +00:00

181 lines
6.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<OctetSequenceKey.Builder>)`.
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`](../jwt-authentication/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`](../jwt-authentication/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.