CORS Security Deep Dive

Resolving the 'Access-Control-Allow-Origin' preflight failure in Spring Security 6 — and understanding the security implications of every setting.

Framework
Spring Boot 3.x
Runtime
Java 21 LTS
Stability
Enterprise grade

Overview

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.

Symptom

exception_report.logFatal
// 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!)

Root cause

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.

Resolution

  1. Define a CorsConfigurationSource @Bean and reference it in your SecurityFilterChain via http.cors(cors -> cors.configurationSource(...)).

  2. Never configure CORS in WebMvcConfigurer only — it runs after Spring Security and will not protect preflight requests.

  3. List specific origins explicitly in production; never use the wildcard '*' with allowCredentials(true) — the browser rejects it.

  4. Set allowedHeaders explicitly, including 'Authorization' and 'Content-Type' — the default allowedHeaders is empty.

  5. Use setMaxAge(3600L) to cache preflight responses in the browser and eliminate redundant OPTIONS round-trips.

Production implementation
SafeJava 21
// ✅ 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

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.

Best practices

  1. Externalize allowed origins to environment configuration — never hardcode https://localhost in your production security config.

  2. Add an integration test using MockMvc that sends an OPTIONS request and asserts the correct Access-Control-Allow-Origin header.

  3. 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.

Hardening CORS Beyond the Basics: Security, Preflight & Credentials

CORS is one of those topics where the minimum viable fix — allowedOrigins("*") — works immediately, ships to production, passes every browser test, and then becomes a security finding in the next penetration test. The browser's same-origin policy exists to protect users, not developers, and a wildcard CORS policy tells the browser to abandon that protection entirely for your API.

✔ What CORS Prevents

A malicious website loaded in a victim's browser from making authenticated cross-origin requests to your API using the victim's cookies or authorization headers. The key word is authenticated — withCredentials: true is what makes the victim's session available to the attacker.

✘ What CORS Does Not Prevent

Server-to-server calls, cURL, Postman, or any HTTP client that does not implement the CORS specification. CORS is a browser-enforced protocol only. Treat it as one layer of a defence-in-depth strategy, not a substitute for authentication.

🔄 Preflight Mechanics & Caching

For any non-simple request the browser sends an OPTIONS preflight before the real request. If you have a custom Spring Security filter chain, the CORS filter must run before the authentication filter — otherwise the OPTIONS request (which carries no credentials) will be rejected with a 401 before the CORS headers are written. In Spring Security 6, use .cors(withDefaults()) with a registered CorsConfigurationSource bean. The Access-Control-Max-Age header (set to 3600) dramatically reduces preflight overhead for SPAs, but during a security incident this cache is your enemy — browsers will continue to see the old policy for up to an hour.

For multi-tenant SaaS applications where allowed origins are stored in a database, implement a dynamic CorsConfigurationSource that resolves the permitted origin list per-request from a Caffeine-backed cache with a one-minute TTL. Validate every stored origin against a strict URL allowlist before registering it and log every CORS rejection at WARN level with the requested origin so your security team can monitor for probing attempts. A well-configured CORS policy is narrow, explicit, and tested — treat it like a firewall rule: default deny, enumerate what you allow, and review the list whenever your frontend topology changes.

Kodivio Team
Kodivio Team

AI & Web Development Specialists

The Kodivio team covers AI tools, automation, and modern web development based on real-world testing and hands-on experience.

Learn more about us →