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.
This commit is contained in:
145
docs/04-csrf-permitall-403.md
Normal file
145
docs/04-csrf-permitall-403.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user