1
0
Files
spring-security-demo/cors-csrf/docs/05-the-error-dispatch.md
2026-08-28 10:16:21 +05:30

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

  1. Something inside the chain calls response.sendError(403, ..) (that is what AccessDeniedHandlerImpl does) or lets an exception escape FilterChainProxy.
  2. The servlet container does not write that response. It re-dispatches the request internally to /error, with DispatcherType.ERROR.
  3. Spring Boot registers springSecurityFilterChain for every dispatcher type: SecurityFilterProperties.dispatcherTypes defaults to EnumSet.allOf(DispatcherType.class). So the whole security chain runs again on that dispatch.
  4. On the second pass, the filters that extend OncePerRequestFilter skip themselves — shouldNotFilterErrorDispatch() defaults to true. BasicAuthenticationFilter is one of them. The credential is never re-read.
  5. The filters that extend GenericFilterBean do run. AuthorizationFilter is one of them.
  6. So the second pass is authorized but not authenticated: AuthorizationFilter evaluates /error against anyRequest().authenticated(), finds an anonymous principal, and denies it.
  7. 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