# 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 audience = new JwtClaimValidator>(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).