Skip to content
Spring Boot sb security 3 min read

CORS with Security

When Spring Security is on the classpath, CORS must be configured inside the security filter chain — not just in MVC. Security’s filters run before the DispatcherServlet, so a CORS setup that lives only in WebMvcConfigurer never gets a chance to answer the browser’s preflight, and cross-origin calls fail with confusing 401/403 errors. This page shows the correct wiring, the interaction with CSRF and authentication, and the misconfigurations that bite everyone. For CORS fundamentals see CORS Configuration.

Why Security needs its own CORS handling

The request order is: servlet filters (Security) first, controllers second. A browser preflight is an OPTIONS request sent without credentials. If Security challenges it for authentication, the browser never sends the real request and you see a CORS error in the console.

Browser preflight:  OPTIONS /api/orders   (no auth header)

   ▼  Security filter chain
   ├─ CorsFilter present?  →  answer 200 with Access-Control-* headers  ✅
   └─ CorsFilter absent?   →  AuthorizationFilter → 401/403, no CORS headers  ❌

Enabling http.cors(...) inserts the CorsFilter early in the chain and exempts preflights, so they are answered correctly.

Define a CorsConfigurationSource bean and reference it from the filter chain with http.cors(...). Both Spring MVC and Security pick up the same bean, so there is a single source of truth.

import org.springframework.context.annotation.*;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.*;
import java.util.List;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(Customizer.withDefaults())   // uses the CorsConfigurationSource bean below
            .csrf(csrf -> csrf.disable())      // stateless API
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("http://localhost:3000", "https://app.example.com"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}

Note: http.cors(Customizer.withDefaults()) tells Security to look for a CorsConfigurationSource bean. If you want a self-contained source you can also write http.cors(c -> c.configurationSource(corsConfigurationSource())).

Interaction with CSRF and authentication

The three concerns layer cleanly when you understand their order:

  1. CORS runs first (CorsFilter) and short-circuits preflight OPTIONS with the right headers — before CSRF or auth can reject them.
  2. CSRF runs next. For stateless token APIs it is disabled; for cookie/session apps it stays on, and your front end must send both the CSRF token and satisfy CORS (see CSRF Protection).
  3. Authentication/authorization runs last on the real request, which now carries credentials.
ConcernFilterApplies to
CORSCorsFilterPreflight + actual cross-origin request
CSRFCsrfFilterState-changing requests (cookie auth)
AuthZAuthorizationFilterEvery request after authentication

Verifying the preflight passes Security

Request:

curl -i -X OPTIONS http://localhost:8080/api/orders \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Authorization, Content-Type"

Output:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600

A 200 with Access-Control-* headers (and no auth challenge) confirms CORS is wired into the chain correctly.

Common misconfigurations

  • CORS only in WebMvcConfigurer, not in Security — preflights die at the AuthorizationFilter; always add http.cors(...).
  • allowCredentials(true) with allowedOrigins("*") — the browser rejects this combination. Use setAllowedOriginPatterns(List.of("*")) if you truly need wildcard origins with credentials.
  • OPTIONS not in allowedMethods — some setups reject the preflight method itself.
  • Custom auth header not in allowedHeadersAuthorization (and any custom header) must be listed, or the browser blocks the real request.
  • Permitting OPTIONS via requestMatchers instead of enabling CORS — works by accident but leaves no CORS headers; enable http.cors(...) properly.

Warning: A misconfigured CORS shows up as a browser console error, not a server-side exception. Inspect the network tab’s preflight response headers when debugging.

Last updated June 13, 2026
Was this helpful?