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:
180
docs/05-hs256-vs-rs256.md
Normal file
180
docs/05-hs256-vs-rs256.md
Normal 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.
|
||||
Reference in New Issue
Block a user