# 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