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
181 lines
6.6 KiB
Markdown
181 lines
6.6 KiB
Markdown
# 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.
|