1
0
Files
spring-security-demo/cors-csrf/docs/01-two-layers.md
2026-08-28 10:16:21 +05:30

3.3 KiB

1. Two layers, one word

Next: 2. Who resolves the CorsConfigurationSource

A Spring Boot application can be told about CORS in two entirely separate places, and the two places do not talk to each other unless you make them.

The MVC layer. WebMvcConfigurer.addCorsMappings(..) and @CrossOrigin register a CorsConfiguration with Spring MVC's handler mappings. It is consulted inside DispatcherServlet, when the request is being matched to a handler method.

The security layer. HttpSecurity.cors(..) puts a org.springframework.web.filter.CorsFilter into the security filter chain. It runs at order 1000 — between HeaderWriterFilter (900) and CsrfFilter (1100), and a long way above AuthorizationFilter (4200).

The security filter chain runs to completion before DispatcherServlet is ever entered. So if the security chain rejects a request, MVC's CORS configuration is not merely ignored: the code that reads it never executes.

Why that specifically breaks preflights

A CORS preflight is not a special protocol. It is an ordinary OPTIONS request carrying two headers:

OPTIONS /api/data HTTP/1.1
Origin: https://spa.example.com
Access-Control-Request-Method: POST

The browser sends it without credentials — no cookies, no Authorization header, by design. Against anyRequest().authenticated() that request is anonymous, and anonymous requests are denied. The rejection happens at order 4200, three thousand two hundred slots before DispatcherServlet and about four thousand before your addCorsMappings call matters.

docs/output/01-mvc-only.txt is that state: an eleven-filter chain with no CorsFilter in it, and a preflight answered 401.

The fix is one line, and it is not on the MVC layer

http.cors(Customizer.withDefaults())

That is MvcBridgeSecurityConfig, and docs/output/02-mvc-bridge.txt is the identical application answering 200. The MVC configuration was fine all along. Nothing was reading it.

CorsFilter short-circuits every preflight it sees:

boolean isValid = this.processor.processRequest(corsConfiguration, request, response);
if (!isValid || CorsUtils.isPreFlightRequest(request)) {
    return;                                   // the chain stops here
}
filterChain.doFilter(request, response);

Read that if carefully, because chapter 2 turns on it: the filter returns on every preflight, whether or not it found a configuration to apply.

One thing that changes when you move the configuration

Moving CORS from addCorsMappings to a CorsConfigurationSource bean is not a pure relocation. Compare the two transcripts:

02-mvc-bridge.txt      Access-Control-Max-Age: 1800
03-security-source.txt (nothing)

CorsRegistration — the builder behind addCorsMappings — defaults maxAge to 1800 seconds. A bare CorsConfiguration leaves it null, and a preflight response with no Access-Control-Max-Age is not cached, so the browser preflights every single cross-origin call. Two round trips instead of one, forever, with nothing in any log to suggest it.

configuration.setMaxAge(1800L);

Next: 2. Who resolves the CorsConfigurationSource