1
0
Files
jwt-auth-demo/docs/04-csrf-permitall-403.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

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: `./scripts/run.sh hs256,csrfon` then `./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