1
0
Files
spring-auth-demo/docs/04-csrf-permitall-403.md
Ankur Mhatre 4dc45d5e00 Add OAuth2 resource server project: JWT validation, JWKS and key rotation
Companion code for the follow-up article. The repository now holds two Maven
projects sharing one docs/ tree:

  jwt-authentication/       the hand-written filter application (unchanged, moved)
  oauth2-resource-server/   a resource server, a Keycloak compose, and a stub
                            issuer whose JWK Set can be mutated on command

The stub exists because Keycloak will not rotate a signing key at a chosen
second, report how many times its JWKS endpoint was fetched, or drop a key from
the published set on request - and the caching and rotation measurements need
all three. The Keycloak run confirms the same code path against a real issuer.

Findings captured under docs/output/, all from real runs:

  * The default validator stack does not check aud. A token minted for another
    service in the same realm is accepted.
  * Spring Security builds its JWKSource with refreshAheadCache(false) and
    rateLimited(false), overriding two of Nimbus's protective defaults, and
    enables Nimbus caching only when NO Spring cache was supplied - so
    supplying one removes the five-minute expiry.
  * A key retired from the JWK Set stops being accepted at t+300s with the
    default cache, and never with a Spring cache that has no TTL.
  * 25 tokens carrying an unknown kid produce 25 JWKS fetches at the issuer,
    through permitAll() endpoints included.
  * A hyphenated client id in an authorities-claim-expression parses as
    subtraction; the SpelEvaluationException is swallowed and logged at TRACE.
  * A clientScopes key in a Keycloak realm import replaces the built-in scopes
    rather than adding to them.

New docs chapters 12-18. README covers both projects. Existing docs and scripts
updated for the new paths; no docs/output/ file from the first article moved, so
links in the published article still resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013f7f2XZXrQ6gW3RtZE187t
2026-08-23 11:00:56 +00:00

146 lines
6.1 KiB
Markdown

# 04 — Why `permitAll()` still returns 403
[← 401 vs 403](03-401-vs-403.md) · [next: HS256 vs RS256 →](05-hs256-vs-rs256.md)
This is the single most reported "Spring Security is broken" bug, and it is not a bug.
## The symptom
```java
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login").permitAll()
.anyRequest().authenticated())
```
```
POST /api/auth/login
Content-Type: application/json
{"username":"alice","password":"alice-password"}
HTTP 403
WWW-Authenticate: Bearer
```
Reproduce it: `./jwt-authentication/scripts/run.sh hs256,csrfon` then `./jwt-authentication/scripts/csrf-demo.sh`. Captured in
[`csrf-vs-permitall.txt`](output/csrf-vs-permitall.txt).
Note the response body is empty and the header mentions `Bearer` — which sends people
hunting for a token problem. There is no token problem.
## The cause, in one number
`CsrfFilter` is filter **5**. `AuthorizationFilter` — the only filter in the entire chain
that has ever heard the word `permitAll` — is filter **12**.
From [`csrf-trace.txt`](output/csrf-trace.txt), a real `TRACE` log of that exact request:
```
DEBUG FilterChainProxy : Securing POST /api/auth/login
TRACE FilterChainProxy : Invoking DisableEncodeUrlFilter (1/12)
TRACE FilterChainProxy : Invoking WebAsyncManagerIntegrationFilter (2/12)
TRACE FilterChainProxy : Invoking SecurityContextHolderFilter (3/12)
TRACE FilterChainProxy : Invoking HeaderWriterFilter (4/12)
TRACE FilterChainProxy : Invoking CsrfFilter (5/12)
TRACE CsrfTokenRequestHandler : Did not find a CSRF token in the [X-XSRF-TOKEN] request header
TRACE CsrfTokenRequestHandler : Did not find a CSRF token in the [_csrf] request parameter
DEBUG CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/auth/login
```
The chain stops at 5 of 12. Filters 6 through 12 never run. `permitAll()` is a statement
about filter 12, and filter 12 is not reached, so `permitAll()` is not a statement about
this request at all.
`CsrfFilter` throws an `AccessDeniedException` subtype — `MissingCsrfTokenException` when
the repository had no stored token (the case above, with a fresh client), or
`InvalidCsrfTokenException` when one existed and did not match. Both log the same
"Invalid CSRF token found" line, so the message does not distinguish them. Neither
reaches `ExceptionTranslationFilter`, which sits at position 11 — downstream of the
filter that threw. `CsrfFilter` has its own `AccessDeniedHandler` and answers directly.
```
1 2 3 4 5 6 7 8 9 10 11 12
|----|----|----|----|----X .
| |
CsrfFilter AuthorizationFilter
403, chain stops knows about permitAll()
never invoked
```
## Why the header says `Bearer`
`CsrfConfigurer` reuses the `AccessDeniedHandler` you configured under
`exceptionHandling()`. Configure `BearerTokenAccessDeniedHandler` for your API — correct
for real authorization failures — and a CSRF rejection is rendered by it too. You get a
403 with `WWW-Authenticate: Bearer` and, because there is no OAuth 2.0 error in context,
no `error=` parameter. A bare `WWW-Authenticate: Bearer` on a 403 is the fingerprint of a
CSRF rejection, not a scope problem.
## Three fixes, in order of preference
### 1. Turn CSRF off — correct for a bearer-token API
```java
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
```
CSRF exists because browsers attach **ambient credentials** — cookies, HTTP Basic,
client certificates — to cross-origin requests automatically. A browser does not
automatically attach an `Authorization: Bearer` header. Your JavaScript has to read the
token out of memory and set it, and same-origin policy stops another site's script from
doing that. No ambient credential, nothing to forge.
The condition is strict, and both halves matter:
- the token is **never** in a cookie, and
- **no** cookie- or session-based authentication remains on any chain.
If you store the JWT in a cookie "for convenience", it *is* an ambient credential, and
you have re-created CSRF exactly. Disabling CSRF at that point is a real vulnerability.
See [doc 07 — token storage](07-edge-cases.md#token-storage).
### 2. Exempt the API, keep it for the browser chain
For a mixed application — a server-rendered admin UI plus a token API:
```java
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))
```
Better still, split into two `SecurityFilterChain` beans with `securityMatcher()`, so the
API chain has no `CsrfFilter` at all rather than one that is told to look away.
### 3. Actually send the token
If the client is a browser SPA that keeps a session:
```java
.csrf(csrf -> csrf.spa()) // Spring Security 7.0+
```
`spa()` bundles `CookieCsrfTokenRepository`, BREACH protection via
`XorCsrfTokenRequestAttributeHandler`, and correct deferred-token loading. The client
reads `XSRF-TOKEN` and echoes it in `X-XSRF-TOKEN`.
Verified: the method does **not** exist in 6.4.7 or 6.5.1 and does exist in 7.0.0, so on
6.x you configure those three pieces individually.
## Related traps
**Only unsafe methods break.** `CsrfFilter` ignores `GET`, `HEAD`, `OPTIONS`, `TRACE`.
A `GET` on a `permitAll()` path works fine, which is why the failure looks intermittent
and endpoint-specific. Step B of the [demo output](output/csrf-vs-permitall.txt) shows
the same profile answering 200 to a `GET`.
**A 403 with an empty body on `POST` only** is CSRF until proven otherwise.
**`MockMvc` hides it.** `spring-security-test`'s `.with(csrf())` post-processor makes the
test pass while production fails. `CsrfBreaksPermitAllTests` deliberately has both: one
test asserting the 403 without it, one asserting the 200 with it.
**CORS is not CSRF.** A preflight `OPTIONS` failing is a `CorsFilter` problem. Spring
Security 7.1 added `PreFlightRequestFilter` CORS support ([gh-18926]); if preflight
requests are being rejected, look there, not at CSRF.
[gh-18926]: https://github.com/spring-projects/spring-security/issues/18926