1
0

Spring Security 7.1 JWT authentication on Spring Boot 4.1

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.
This commit is contained in:
2026-08-22 06:22:25 +00:00
commit 4a8dab6739
57 changed files with 4339 additions and 0 deletions

180
docs/05-hs256-vs-rs256.md Normal file
View File

@@ -0,0 +1,180 @@
# 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.