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:
286
docs/07-edge-cases.md
Normal file
286
docs/07-edge-cases.md
Normal 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 17–18 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 (5–15
|
||||
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 15–16 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).
|
||||
Reference in New Issue
Block a user