3.6 KiB
5. The /error dispatch, or why your 403 arrives as a 401
Prev: 4. Preflight handlers · Next: 6. CSRF for SPAs
Two of the failures in this module — the wildcard/credentials clash in chapter 4 and the CSRF rejection in chapter 6 — produce a status code that has nothing to do with what went wrong. Both have the same cause, and it is worth understanding once because it explains a large fraction of confusing Spring Security bug reports.
The mechanism
- Something inside the chain calls
response.sendError(403, ..)(that is whatAccessDeniedHandlerImpldoes) or lets an exception escapeFilterChainProxy. - The servlet container does not write that response. It re-dispatches the request
internally to
/error, withDispatcherType.ERROR. - Spring Boot registers
springSecurityFilterChainfor every dispatcher type:SecurityFilterProperties.dispatcherTypesdefaults toEnumSet.allOf(DispatcherType.class). So the whole security chain runs again on that dispatch. - On the second pass, the filters that extend
OncePerRequestFilterskip themselves —shouldNotFilterErrorDispatch()defaults totrue.BasicAuthenticationFilteris one of them. The credential is never re-read. - The filters that extend
GenericFilterBeando run.AuthorizationFilteris one of them. - So the second pass is authorized but not authenticated:
AuthorizationFilterevaluates/erroragainstanyRequest().authenticated(), finds an anonymous principal, and denies it. - The 401 from step 6 is what reaches the client. The 403 from step 1 is gone.
The mechanism is set out in full in The Spring Security Filter Chain Explained; this chapter is what it looks like when it lands on a CORS or CSRF problem.
Proving it in one diff
The errorpermit profile adds one filter chain, @Order(0), matching /error and permitting
everything. Nothing else changes.
./scripts/run.sh csrfnaive POST → 401, empty body, WWW-Authenticate: Basic
./scripts/run.sh csrfnaive,errorpermit POST → 403, {"status":403,"error":"Forbidden", ...}
docs/output/08-csrf-naive.txt against
docs/output/09-error-dispatch.txt.
What to do about it
Permit /error. It is not a hole: the error page is generated from an attribute the container
set, and an unauthenticated request cannot reach it except through a dispatch the container
initiated.
@Bean
@Order(0)
SecurityFilterChain errorChain(HttpSecurity http) throws Exception {
return http.securityMatcher("/error")
.authorizeHttpRequests((auth) -> auth.anyRequest().permitAll())
.csrf(CsrfConfigurer::disable)
.build();
}
The alternative is to narrow the dispatcher types so the chain does not run on the error dispatch at all:
spring.security.filter.dispatcher-types: request
That one is broader in effect than it looks; prefer the /error chain unless you have a
specific reason.
Why this matters more for a SPA than for a server-rendered app
A browser will not let a SPA read a cross-origin response unless the CORS headers are present.
When the 403 is replaced by a 401 written on a dispatch where CorsFilter may or may not have
re-run, what the developer sees in the console is neither "403" nor "CSRF"; it is
TypeError: Failed to fetch. Every layer of the stack has thrown away the actual cause by then.
Prev: 4. Preflight handlers · Next: 6. CSRF for SPAs