1
0
Files
jwt-auth-demo/docs/07-edge-cases.md
asmhatre 4a8dab6739 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.
2026-08-22 06:34:43 +00:00

11 KiB
Raw Permalink Blame History

07 — Edge cases

← SecurityContext · next: testing →

Eighteen things that bite. Each is stated as the surprise, then the cause, then the fix.


1. aud is not validated by default

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.

OAuth2TokenValidator<Jwt> audience =
        new JwtClaimValidator<List<String>>(JwtClaimNames.AUD, aud -> aud.contains("payments-api"));

decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
        JwtValidators.createDefaultWithIssuer(issuer), audience));

See AudienceValidator and JwtValidatorFactory.


2. A refresh token is a valid 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 vs strict:

# 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.


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, 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:

new DelegatingOAuth2TokenValidator<>(
        new JwtTimestampValidator(Duration.ofSeconds(5)),
        new JwtIssuerValidator(issuerUri));

4. A JWT cannot be revoked

"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.

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 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.

this.revokedTokens.revoke(jwt.getId(), jwt.getExpiresAt());   // spend it

Steps 1516 of the transcript: 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

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. 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 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, 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:

.requestMatchers("/api/admin/**").hasRole("ADMIN")            // needs ROLE_ADMIN
.requestMatchers("/api/reports").hasAuthority("SCOPE_admin:read")

This repository carries both families and maps them separately — scopeSCOPE_x, rolesROLE_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

.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:

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.


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, 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:

"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.