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.
287 lines
11 KiB
Markdown
287 lines
11 KiB
Markdown
# 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).
|