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

286
docs/07-edge-cases.md Normal file
View File

@@ -0,0 +1,286 @@
# 07 — Edge cases
[← SecurityContext](06-securitycontext-and-statelessness.md) · [next: testing →](08-testing.md)
Eighteen things that bite. Each is stated as the surprise, then the cause, then the fix.
---
## 1. `aud` is not validated by default {#audience}
`JwtValidators.createDefaultWithIssuer(issuer)` validates `exp`, `nbf` and `iss`. It does
**not** validate `aud`. In an estate where every service trusts the same issuer, a token
minted for the reporting API is accepted by the payments API without complaint. That is a
confused-deputy vulnerability arriving by default.
```java
OAuth2TokenValidator<Jwt> audience =
new JwtClaimValidator<List<String>>(JwtClaimNames.AUD, aud -> aud.contains("payments-api"));
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer), audience));
```
See [`AudienceValidator`](../src/main/java/com/ankurm/jwtauth/edge/AudienceValidator.java)
and [`JwtValidatorFactory`](../src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java).
---
## 2. A refresh token is a valid access token {#refresh-token-as-access-token}
Both are signed by the same key. Both have a valid `exp`, `iss`, `aud`. Every default
validator passes. If the only difference is the TTL, a stolen refresh token is a
*long-lived* access token.
Proof, from two runs of the same code —
[loose](output/resource-server-loose.txt) vs [strict](output/resource-server-strict.txt):
```
# 4. REFRESH token presented as an access token.
HTTP 200 <-- profiles: hs256,resourceserver
HTTP 401 <-- profiles: hs256,resourceserver,strict
```
The 200 is worth reading closely: authorities come back as `["FACTOR_BEARER"]` — no roles,
no scopes. The caller is authenticated as alice with no privileges, so `/api/me` succeeds
while `/api/admin/stats` does not. A partial compromise is still a compromise.
Fix: a `token_type` claim and a validator that checks it —
[`AccessTokenTypeValidator`](../src/main/java/com/ankurm/jwtauth/edge/AccessTokenTypeValidator.java).
---
## 3. Sixty seconds of clock skew
`JwtTimestampValidator` allows **60 seconds** of clock skew by default, so a token is
still accepted a minute after `exp`. From
[`expiry-and-clock-skew.txt`](output/expiry-and-clock-skew.txt), with a 2-second TTL:
```
# T+0s - fresh token HTTP 200
# T+5s - exp has passed, still within the skew window HTTP 200
# T+65s - past exp + 60s HTTP 401
```
This is correct behaviour and usually what you want. It matters in two places: a test
that sleeps past `exp` and asserts 401 will fail, and a "revoke by shortening TTL"
strategy has a minute of lag. To tighten it:
```java
new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(Duration.ofSeconds(5)),
new JwtIssuerValidator(issuerUri));
```
---
## 4. A JWT cannot be revoked {#logout-and-revocation}
"Logout" that deletes the token client-side is not revocation — the token stays valid
until `exp` and works from anywhere it was copied. The minimum viable fix is a `jti`
claim plus a denylist checked on every request:
[`RevokedTokenStore`](../src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java).
```java
if (this.revokedTokens.isRevoked(jwt.getId())) {
throw invalidToken("Token has been revoked");
}
```
Entries need only outlive the token's own `exp`, so the store self-prunes; in production
this is Redis with a TTL. Steps 1718 of the
[transcript](output/curl-transcript-hs256.txt) show a cryptographically valid token
refused after logout.
Accept the trade-off honestly: you have reintroduced a per-request lookup on shared
state, which is the thing JWTs were supposed to avoid. Short access-token TTLs (515
minutes) plus a denylist only for high-value events (password change, logout-all,
compromise) is the usual compromise.
---
## 5. Rotate refresh tokens, or replay is undetectable
If a refresh token is reusable, a stolen one is usable until it expires and you will
never know. Rotation — issue a new refresh token and revoke the presented one — turns
replay into a signal.
```java
this.revokedTokens.revoke(jwt.getId(), jwt.getExpiresAt()); // spend it
```
Steps 1516 of the [transcript](output/curl-transcript-hs256.txt): the second use of the
same refresh token is a 401. In production, a replay should invalidate the **whole
token family** for that user, since either the client or the attacker is now holding a
stale token and you cannot tell which.
---
## 6. Token storage: `localStorage` vs cookies {#token-storage}
| | `localStorage` | `httpOnly` cookie |
|---|---|---|
| XSS | readable by any injected script | not readable |
| CSRF | immune (not ambient) | vulnerable — needs CSRF protection back on |
| mobile / non-browser | fine | awkward |
There is no free option. `localStorage` trades XSS exposure for CSRF immunity; cookies
do the reverse. If you pick cookies, **you must re-enable CSRF** — see
[doc 04](04-csrf-permitall-403.md). The failure mode is picking cookies for XSS safety
and keeping `csrf.disable()` from the tutorial you started with.
The strongest common pattern: short-lived access token in memory only (never persisted),
refresh token in an `httpOnly`, `Secure`, `SameSite=Strict` cookie scoped to the refresh
endpoint, with CSRF protection on that one endpoint.
---
## 7. JWTs are signed, not encrypted
Base64url is not encryption. Step 6 of the
[transcript](output/curl-transcript-hs256.txt) decodes a token with `base64 -d` and no
key. Anything in the claims is readable by the holder, by proxies that log the header, and
by anything that ends up with the string.
Never put in claims: email addresses, phone numbers, internal user IDs you would not
publish, permission structures that describe your authorization model, PII of any kind.
If the payload must be confidential, that is JWE (`nimbus-jose-jwt` supports it), not JWS —
and the usual right answer is to put an opaque identifier in the token and look the rest up.
---
## 8. Bigger tokens are a real cost
`Authorization` headers travel on **every** request. From
[`rs256-demo.txt`](output/rs256-demo.txt), a modest RS256 token is 758 characters;
the signature alone is 342. Add a `permissions` array with 200 entries and you are near
common proxy header limits (nginx `large_client_header_buffers` defaults to 8 KB; some
API gateways are stricter). The failure is a **431** or a silent truncation, not a
security error, and it appears only for your most privileged users — who have the most
permissions and complain the loudest.
Put roles in the token, not permissions. Resolve permissions server-side.
---
## 9. Authority prefixes: `ROLE_` vs `SCOPE_`
`JwtGrantedAuthoritiesConverter` defaults to reading the `scope` (or `scp`) claim and
prefixing each value with `SCOPE_`. Meanwhile `hasRole("ADMIN")` looks for `ROLE_ADMIN`
and `hasAuthority("ADMIN")` looks for exactly `ADMIN`. Three conventions, easily crossed:
```java
.requestMatchers("/api/admin/**").hasRole("ADMIN") // needs ROLE_ADMIN
.requestMatchers("/api/reports").hasAuthority("SCOPE_admin:read")
```
This repository carries both families and maps them separately —
`scope``SCOPE_x`, `roles``ROLE_x` — with
`DelegatingJwtGrantedAuthoritiesConverter` in the resource-server profile. Spring Boot 4.1
also added `spring.security.oauth2.resourceserver.jwt.authorities-claim-expressions`,
a SpEL-based extractor for claims nested deeper than the top level (mutually exclusive
with `authorities-claim-name`).
---
## 10. `@PreAuthorize` on a non-public method silently does nothing
Method security is proxy-based. A `@PreAuthorize` on a `private`, `final`, or
package-private method, or on a method called from **within the same class**, is not
intercepted. There is no warning. The endpoint is simply unprotected.
Keep authorization on public methods invoked through the proxy, and prefer
`authorizeHttpRequests` for coarse URL rules.
---
## 11. `permitAll()` does not mean "no authentication"
It means "authorization always grants". If a token *is* present, it is still decoded, and
a **bad** token on a `permitAll()` endpoint still fails — the filter rejects it before
authorization runs. This is correct: a caller sending a broken token deserves to be told,
not silently downgraded to anonymous.
Where it surprises people: health checks that pass through an expired token from a
sidecar start failing on an endpoint that is supposedly public.
---
## 12. Ordering inside `authorizeHttpRequests` is first-match
```java
.anyRequest().authenticated()
.requestMatchers("/api/public/**").permitAll() // unreachable
```
Rules are evaluated top to bottom and the first match wins. `anyRequest()` must be last.
Spring Security 7 throws at startup for an unreachable matcher in many cases, but not all
— put the specific rules first regardless.
---
## 13. The `Authorization` header can be stripped in transit
Some proxies, load balancers and CDN configurations drop or rewrite `Authorization`.
Symptom: works locally, 401 everywhere else, and the application log shows no token at
all. Check the edge before the application. `DefaultBearerTokenResolver` also supports a
query parameter, but do **not** enable it:
```java
resolver.setAllowUriQueryParameter(true); // don't
```
URLs land in access logs, browser history, and `Referer` headers.
---
## 14. Two tokens in one request is an error, not a preference
`DefaultBearerTokenResolver` throws `OAuth2AuthenticationException` when a token appears
in both the header and a parameter, rather than picking one. Correct — but it means a
client that "helpfully" adds both gets a 401 with `invalid_request` and no obvious cause.
---
## 15. `WWW-Authenticate` needs a `BearerTokenError` to say anything
Wrap a `JwtException` in a plain `AuthenticationServiceException` and the 401 carries a
bare `WWW-Authenticate: Bearer realm="…"`. Wrap it in `InvalidBearerTokenException` and it
carries `error="invalid_token"` with a description. Same status code, very different
debuggability. Compare steps 11 and 13 of the
[transcript](output/curl-transcript-hs256.txt).
---
## 16. `error_description` leaks
The flip side: `"Jwt expired at 2026-08-22T06:01:43Z"` tells a caller exactly when the
token expired, and issuer/audience mismatches name your internal URLs. Useful in
development, informative to an attacker in production. Consider a production
`AuthenticationEntryPoint` that logs the detail and returns a generic body.
---
## 17. The `SecurityContext` does not cross threads
Covered in [doc 06](06-securitycontext-and-statelessness.md#the-thread-boundary), listed
here because it is the edge case that most often reaches production: it only manifests
under `@Async`, `CompletableFuture`, or a `parallelStream()`, none of which are on the
happy path. `GET /api/async-demo` demonstrates it live. {#async}
---
## 18. `FACTOR_BEARER` appears in your authorities
New in Spring Security 7: authenticating with a bearer token adds a `FACTOR_BEARER`
authority alongside your own. Visible in every `/api/me` response in the
[transcript](output/curl-transcript-hs256.txt):
```json
"authorities": ["FACTOR_BEARER", "ROLE_USER", "SCOPE_profile:read"]
```
It exists to support the new multi-factor authorization support
(`AuthorizationManagerFactories.multiFactor()`, `@EnableMultiFactorAuthentication`). It
is harmless — until a test asserts on the exact authority set, or code assumes every
authority starts with `ROLE_` or `SCOPE_`. See [doc 11](11-spring-security-7-changes.md).