CORS: Cross-Origin Requests and Preflight Configuration

What Is CORS?

The Same-Origin Policy (SOP) is a browser security rule: JavaScript on app.example.com cannot read responses from api.example.com — different subdomain, different origin. Without this, any website could use a visitor’s authenticated session to silently call other sites on their behalf.

CORS (Cross-Origin Resource Sharing) is the mechanism by which a server explicitly relaxes SOP for trusted origins. When a browser sees a cross-origin request, it checks whether the server has granted permission before allowing JavaScript to read the response.

CORS is a browser enforcement mechanism. Non-browser clients (curl, Postman, server-to-server) are unaffected — they ignore CORS headers and always receive the full response.


Simple vs. Preflighted Requests

Not all cross-origin requests trigger a preflight.

Simple Requests (No Preflight)

A request is “simple” if it meets all of:

  • Method is GET, POST, or HEAD
  • Headers are only Accept, Accept-Language, Content-Language, Content-Type
  • Content-Type is text/plain, multipart/form-data, or application/x-www-form-urlencoded

Simple requests are sent directly. The server receives the request, and the browser decides whether to expose the response to JavaScript based on Access-Control-Allow-Origin.

Preflighted Requests

Any request that doesn’t meet the “simple” criteria triggers a preflight — the browser sends an OPTIONS request first:

OPTIONS /api/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

The server must respond with appropriate CORS headers:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 3600

If the preflight fails, the browser never sends the actual request. Your API code never sees it.


CORS in Spring Security

Critical point: Spring Security filters run before Spring MVC. If your security config blocks an OPTIONS preflight request (e.g. because it is unauthenticated), CORS never gets a chance to add its headers and the browser sees a blocked response. CORS must be configured at the Spring Security level, not just in Spring MVC.


Configuration: @CrossOrigin (Method/Class Level)

The simplest option — annotate specific controllers:

@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = "https://app.example.com", maxAge = 3600)
public class UserController {

    @GetMapping
    public List<User> list() { ... }

    @CrossOrigin(origins = "https://admin.example.com")  // overrides class-level
    @GetMapping("/admin")
    public List<User> adminList() { ... }
}

@CrossOrigin is convenient but scattered — it is hard to audit or enforce CORS policy across many controllers.


Configuration: Global CorsConfigurationSource

Define CORS policy once and apply it application-wide via the security config:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .csrf(csrf -> csrf.disable())  // stateless API with JWT
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            );
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();

        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", "X-Page-Number"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);

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

Configuration Properties Explained

PropertyPurpose
allowedOriginsDomains allowed to make cross-origin requests
allowedMethodsHTTP methods permitted in CORS requests
allowedHeadersRequest headers JavaScript can include
exposedHeadersResponse headers JavaScript is allowed to read
allowCredentialsWhether cookies/auth headers are allowed
maxAgeHow long browsers cache preflight results (seconds)

allowCredentials and allowedOrigins

allowCredentials(true) is required when the browser sends cookies or Authorization headers with cross-origin requests. However, it cannot be combined with a wildcard origin:

// INVALID — Spring Security will throw an exception
config.setAllowedOrigins(List.of("*"));
config.setAllowCredentials(true);
// CORRECT — explicit origins required with credentials
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowCredentials(true);

If you need to support many origins dynamically (e.g. tenant subdomains), use a pattern:

config.setAllowedOriginPatterns(List.of("https://*.example.com"));
config.setAllowCredentials(true);

allowedOriginPatterns supports wildcards and works alongside allowCredentials.


Per-Path CORS Configuration

Apply different CORS policies to different path prefixes:

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

    // Public API — any origin, no credentials
    CorsConfiguration publicConfig = new CorsConfiguration();
    publicConfig.setAllowedOrigins(List.of("*"));
    publicConfig.setAllowedMethods(List.of("GET", "OPTIONS"));
    publicConfig.setAllowedHeaders(List.of("Content-Type"));
    source.registerCorsConfiguration("/api/public/**", publicConfig);

    // Private API — specific origins, with credentials
    CorsConfiguration privateConfig = new CorsConfiguration();
    privateConfig.setAllowedOrigins(List.of("https://app.example.com"));
    privateConfig.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    privateConfig.setAllowedHeaders(List.of("Authorization", "Content-Type"));
    privateConfig.setAllowCredentials(true);
    privateConfig.setMaxAge(3600L);
    source.registerCorsConfiguration("/api/**", privateConfig);

    return source;
}

Paths are matched in registration order — more specific paths should be registered first.


Environment-Specific Allowed Origins

Hardcoding origins in Java breaks local development vs. production:

# application.properties
cors.allowed-origins=https://app.example.com,https://admin.example.com

# application-local.properties
cors.allowed-origins=http://localhost:3000,http://localhost:4200
@Configuration
public class CorsProperties {
    @Value("${cors.allowed-origins}")
    private List<String> allowedOrigins;

    public List<String> getAllowedOrigins() {
        return allowedOrigins;
    }
}

@Bean
public CorsConfigurationSource corsConfigurationSource(CorsProperties corsProperties) {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(corsProperties.getAllowedOrigins());
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
    config.setAllowCredentials(true);
    config.setMaxAge(3600L);

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

CORS and CSRF Interaction

CORS and CSRF solve related but distinct problems:

  • CORS controls which origins can read your responses
  • CSRF protects against forged requests using session cookies

For stateless JWT APIs: CORS is relevant (controls SPA access), CSRF is not (no session cookies).

For cookie-based form apps: both matter. CORS restricts which origins can make requests; CSRF adds a token verification layer on top.

A common mistake:

// Wrong mental model: "CORS prevents cross-origin requests, so CSRF is redundant"
// Actually: CORS headers are the server's response to the browser's question.
// A malicious site can still submit a form (simple request, no preflight) to your server.
// The browser sends the request; CORS only controls whether the response is returned to JS.
// CSRF tokens prevent the request from being accepted regardless of how it arrived.

Debugging CORS Issues

CORS errors show in the browser console, not in server logs. Common error messages and causes:

ErrorCause
No 'Access-Control-Allow-Origin' headerServer is not sending CORS headers — security filter blocked the request, or CORS not configured
CORS header 'Access-Control-Allow-Origin' does not matchRequested origin not in allowedOrigins
Method not allowed by CORS policyHTTP method not in allowedMethods
Request header not allowedCustom header not in allowedHeaders
Credential is not supported if the CORS header is '*'Cannot use allowedOrigins("*") with allowCredentials(true)

Debugging Checklist

  1. Check that OPTIONS preflight returns 200 (not 401/403)
  2. Verify the Origin header in the request matches an allowed origin exactly (scheme, host, port all matter)
  3. Check allowedHeaders includes all custom headers the browser is sending
  4. If using cookies, verify allowCredentials(true) and explicit (non-wildcard) origin

Key Takeaways

  • CORS is enforced by browsers — curl and server-to-server calls are unaffected
  • Configure CORS at the Spring Security level via CorsConfigurationSource — not just Spring MVC — so preflight OPTIONS requests are not blocked by security filters
  • allowCredentials(true) requires explicit (non-wildcard) origins in allowedOrigins or allowedOriginPatterns
  • Use per-path configuration for different policies across public and private API segments
  • CORS and CSRF solve different problems and both may be needed for cookie-based apps
  • Externalize allowed origins to properties for environment-specific configuration

Next: Security Headers: CSP, HSTS, Clickjacking Protection — configure the HTTP security headers that defend against a broad class of browser-based attacks.