Runnable companion for https://ankurm.com/spring-security-7-1-jwt-authentication-guide/ - login -> token issue -> OncePerRequestFilter -> SecurityContext, end to end - HS256 and RS256 variants (RS256 publishes a real JWKS endpoint) - the same API secured by the built-in oauth2ResourceServer().jwt(), for comparison - 11 documentation chapters under docs/, interlinked with the code - docs/output/ is real captured output, regenerated by scripts/run-all.sh - 13 passing tests pinning the 401-vs-403 contract and the CSRF failure Verified against Spring Boot 4.1.1, Spring Security 7.1.1, JDK 25.0.4.1.
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`](../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.
|