Technical Briefing
Cross-Origin Resource Sharing (CORS) errors are among the most frustrating to diagnose because they masquerade as server authentication failures. In Spring Boot 3 / Spring Security 6, CORS must be configured at the Security Filter Chain level โ not just via @CrossOrigin or WebMvcConfigurer. If Spring Security intercepts the OPTIONS preflight request before your application code runs, the browser reports a CORS violation regardless of your MVC configuration.
โ Signal Detected
// Browser DevTools Console Access to fetch at 'https://api.example.com/users' from origin 'https://app.example.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. // Network Tab: OPTIONS /users โ HTTP 401 Unauthorized // (Not a CORS issue โ Spring Security blocked the preflight!)
โ Trace Analysis
Browsers send a preliminary OPTIONS request (preflight) before any non-simple cross-origin request. Spring Security evaluates this OPTIONS request against your security rules first. Unless you explicitly permit unauthenticated OPTIONS requests, Spring Security returns 401 or 403 โ which the browser interprets as a CORS failure, even though it's actually an authentication failure. The solution requires wiring CORS configuration directly into the SecurityFilterChain.
โฆ Remediation Plan
Define a CorsConfigurationSource @Bean and reference it in your SecurityFilterChain via http.cors(cors -> cors.configurationSource(...)).
Never configure CORS in WebMvcConfigurer only โ it runs after Spring Security and will not protect preflight requests.
List specific origins explicitly in production; never use the wildcard '*' with allowCredentials(true) โ the browser rejects it.
Set allowedHeaders explicitly, including 'Authorization' and 'Content-Type' โ the default allowedHeaders is empty.
Use setMaxAge(3600L) to cache preflight responses in the browser and eliminate redundant OPTIONS round-trips.
// โ SPRING SECURITY 6 โ COMPLETE CORS SETUP class="hi-ann">@Configuration class="hi-ann">@EnableWebSecurity public class SecurityConfig { class="hi-ann">@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http // 1. Wire CORS before any auth checks .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(csrf -> csrf.disable()) // Use stateless JWT or CSRF tokens separately .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // Explicit preflight permit .anyRequest().authenticated() ); return http.build(); } class="hi-ann">@Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); // Explicit origins only โ never "*" with credentials config.setAllowedOrigins(List.of( "https://app.example.com", "https://admin.example.com" )); config.setAllowedMethods(List.of("GET","POST","PUT","PATCH","DELETE","OPTIONS")); config.setAllowedHeaders(List.of("Authorization","Content-Type","X-Requested-With")); config.setExposedHeaders(List.of("X-Total-Count","Location")); config.setAllowCredentials(true); config.setMaxAge(3600L); // Cache preflight 1 hour UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; } } # application.yml โ Environment-specific origins cors: allowed-origins: - https://app.example.com # dev profile overrides: # allowed-origins: # - http://localhost:3000
โ Engineering Deep-Dive
The Preflight Request Lifecycle
Before sending a cross-origin request with a custom header (like Authorization) or a non-GET/POST method, the browser automatically issues an OPTIONS preflight. Your server must respond within 200โ204 with the correct Access-Control-Allow-* headers. If it doesn't โ including if it returns 401 โ the browser aborts the actual request and reports a CORS error.
This is why Spring Security's authentication filter is the root cause, not a CORS misconfiguration in MVC.
allowCredentials + Wildcard Origin = Fatal
Setting allowCredentials(true) tells the browser to include cookies or Authorization headers. Browsers explicitly reject Access-Control-Allow-Origin: * combined with Access-Control-Allow-Credentials: true as a security measure. Always enumerate allowed origins when using credentials.
CORS vs. CSRF
CORS limits which origins can make cross-origin requests from the browser. CSRF protection prevents a malicious site from tricking a user's browser into making authenticated requests to your server. For stateless JWT APIs, disable CSRF (the token is not auto-sent). For session-based apps, keep CSRF protection enabled alongside CORS.
โ Elite Standards
Engineering Rule
Externalize allowed origins to environment configuration โ never hardcode https://localhost in your production security config.
Engineering Rule
Add an integration test using MockMvc that sends an OPTIONS request and asserts the correct Access-Control-Allow-Origin header.
Engineering Rule
Pair CORS with a strict Content-Security-Policy header to prevent XSS-based CORS bypass attacks.
FAQ
- What causes CORS Security Deep Dive in Spring Boot 3?
- Browsers send a preliminary OPTIONS request (preflight) before any non-simple cross-origin request. Spring Security evaluates this OPTIONS request against your security rules first. Unless you explicitly permit unauthenticated OPTIONS requests, Spring Security returns 401 or 403 โ which the browser interprets as a CORS failure, even though it's actually an authentication failure. The solution requires wiring CORS configuration directly into the SecurityFilterChain.
- How do I fix CORS Security Deep Dive?
- Define a CorsConfigurationSource @Bean and reference it in your SecurityFilterChain via http.cors(cors -> cors.configurationSource(...)). Never configure CORS in WebMvcConfigurer only โ it runs after Spring Security and will not protect preflight requests. List specific origins explicitly in production; never use the wildcard '*' with allowCredentials(true) โ the browser rejects it. Set allowedHeaders explicitly, including 'Authorization' and 'Content-Type' โ the default allowedHeaders is empty. Use setMaxAge(3600L) to cache preflight responses in the browser and eliminate redundant OPTIONS round-trips.
- Best practice #1 for preventing Security ยท HTTP errors?
- Externalize allowed origins to environment configuration โ never hardcode https://localhost in your production security config.
- Best practice #2 for preventing Security ยท HTTP errors?
- Add an integration test using MockMvc that sends an OPTIONS request and asserts the correct Access-Control-Allow-Origin header.
- Best practice #3 for preventing Security ยท HTTP errors?
- Pair CORS with a strict Content-Security-Policy header to prevent XSS-based CORS bypass attacks.