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, orHEAD - Headers are only
Accept,Accept-Language,Content-Language,Content-Type Content-Typeistext/plain,multipart/form-data, orapplication/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
| Property | Purpose |
|---|---|
allowedOrigins | Domains allowed to make cross-origin requests |
allowedMethods | HTTP methods permitted in CORS requests |
allowedHeaders | Request headers JavaScript can include |
exposedHeaders | Response headers JavaScript is allowed to read |
allowCredentials | Whether cookies/auth headers are allowed |
maxAge | How 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:
| Error | Cause |
|---|---|
No 'Access-Control-Allow-Origin' header | Server is not sending CORS headers — security filter blocked the request, or CORS not configured |
CORS header 'Access-Control-Allow-Origin' does not match | Requested origin not in allowedOrigins |
Method not allowed by CORS policy | HTTP method not in allowedMethods |
Request header not allowed | Custom header not in allowedHeaders |
Credential is not supported if the CORS header is '*' | Cannot use allowedOrigins("*") with allowCredentials(true) |
Debugging Checklist
- Check that
OPTIONSpreflight returns 200 (not 401/403) - Verify the
Originheader in the request matches an allowed origin exactly (scheme, host, port all matter) - Check
allowedHeadersincludes all custom headers the browser is sending - 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 preflightOPTIONSrequests are not blocked by security filters allowCredentials(true)requires explicit (non-wildcard) origins inallowedOriginsorallowedOriginPatterns- 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.