Contents
- What is CORS
- @CrossOrigin on Controllers
- Global CORS with WebMvcConfigurer
- CORS with Spring Security
- CORS Configuration Properties
- Preflight Requests
- Common CORS Errors
- Production Best Practices
Browsers enforce the Same-Origin Policy — JavaScript on https://app.example.com cannot make requests to https://api.example.com unless the server explicitly allows it. An origin is a combination of scheme, host, and port. Any difference in these three parts means a different origin.
CORS (Cross-Origin Resource Sharing) is the HTTP-header-based protocol that relaxes this policy. The browser and server exchange a set of Access-Control-* headers to negotiate whether the cross-origin request is permitted.
Key CORS Response Headers
| Header | Purpose |
| Access-Control-Allow-Origin | Which origins are allowed (e.g. https://app.example.com or *) |
| Access-Control-Allow-Methods | Permitted HTTP methods (GET, POST, PUT, DELETE, etc.) |
| Access-Control-Allow-Headers | Permitted request headers (e.g. Authorization, Content-Type) |
| Access-Control-Allow-Credentials | Whether cookies / authorization headers are allowed (true / false) |
| Access-Control-Max-Age | How long (seconds) the browser may cache the preflight response |
| Access-Control-Expose-Headers | Which response headers the browser can expose to JS |
Simple vs. Preflight Requests
A simple request uses GET, HEAD, or POST with standard headers and content types — the browser sends it directly and checks the response headers. Any other combination triggers a preflight: the browser sends an OPTIONS request first, and only proceeds with the actual request if the server responds with the correct CORS headers.
Common triggers for preflight: using PUT or DELETE methods, sending Authorization headers, or using Content-Type: application/json.
The simplest way to enable CORS in Spring Boot is the @CrossOrigin annotation. It can be applied at the class level (affects all handler methods) or at the method level.
Class-Level @CrossOrigin
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/products")
@CrossOrigin(origins = "https://app.example.com")
public class ProductController {
@GetMapping
public List<Product> listProducts() {
return productService.findAll();
}
@PostMapping
public Product createProduct(@RequestBody Product product) {
return productService.save(product);
}
}
Every endpoint in ProductController now allows cross-origin requests from https://app.example.com.
Method-Level @CrossOrigin
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@CrossOrigin(origins = {"https://app.example.com", "https://admin.example.com"},
methods = {RequestMethod.GET, RequestMethod.POST},
allowedHeaders = {"Authorization", "Content-Type"},
maxAge = 3600)
@GetMapping
public List<Order> listOrders() {
return orderService.findAll();
}
// This endpoint has NO CORS — browser blocks cross-origin access
@DeleteMapping("/{id}")
public void deleteOrder(@PathVariable Long id) {
orderService.delete(id);
}
}
@CrossOrigin Attribute Reference
| Attribute | Default | Description |
| origins | * | Allowed origins (use originPatterns for wildcards like *.example.com) |
| methods | The mapped method(s) | Allowed HTTP methods |
| allowedHeaders | * | Allowed request headers |
| exposedHeaders | empty | Response headers exposed to JavaScript |
| allowCredentials | false | Whether to allow cookies and auth headers |
| maxAge | 1800 (30 min) | Preflight cache duration in seconds |
When allowCredentials is set to true, you cannot use origins = "*". You must specify exact origins or use originPatterns instead.
For applications with many controllers, configuring CORS globally via WebMvcConfigurer avoids repeating the @CrossOrigin annotation everywhere.
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://app.example.com", "https://admin.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH")
.allowedHeaders("Authorization", "Content-Type", "X-Request-ID")
.exposedHeaders("X-Total-Count", "X-Request-ID")
.allowCredentials(true)
.maxAge(3600);
// Separate config for public endpoints
registry.addMapping("/public/**")
.allowedOrigins("*")
.allowedMethods("GET")
.maxAge(86400);
}
}
The addMapping() method accepts Ant-style path patterns. You can chain multiple addMapping() calls to define different CORS policies for different URL namespaces.
Global CORS configuration via WebMvcConfigurer and @CrossOrigin annotations are additive. If both are present, Spring merges the configurations — the most permissive setting wins for each attribute.
When Spring Security is on the classpath, you must configure CORS through the security filter chain. Spring Security's CorsFilter runs before your controllers, so it intercepts and handles preflight requests. Without this, the security layer blocks OPTIONS requests before they reach the MVC CORS processor.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.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", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Request-ID"));
config.setExposedHeaders(List.of("X-Total-Count"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
If you use cors(Customizer.withDefaults()) without defining a CorsConfigurationSource bean, Spring Security looks for a bean named corsConfigurationSource in the application context. Make sure the bean name matches or pass the source explicitly.
Using Environment-Specific Origins
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
public class CorsSourceConfig {
@Value("${cors.allowed-origins}")
private List<String> allowedOrigins;
@Value("${cors.allowed-methods:GET,POST,PUT,DELETE}")
private List<String> allowedMethods;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(allowedOrigins);
config.setAllowedMethods(allowedMethods);
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
Then in application.yml:
# application-dev.yml
cors:
allowed-origins: http://localhost:3000,http://localhost:5173
allowed-methods: GET,POST,PUT,DELETE,PATCH
# application-prod.yml
cors:
allowed-origins: https://app.example.com,https://admin.example.com
allowed-methods: GET,POST,PUT,DELETE
Whether you use @CrossOrigin, WebMvcConfigurer, or CorsConfigurationSource, the underlying settings are the same. Here is a detailed breakdown of each property.
| Property | Type | Description |
| allowedOrigins |
List<String> |
Exact origin URLs. Use "*" to allow all (cannot combine with credentials). Example: https://app.example.com |
| allowedOriginPatterns |
List<String> |
Pattern-based origins with wildcard support. Example: https://*.example.com. Compatible with allowCredentials(true). |
| allowedMethods |
List<String> |
HTTP methods to allow. Default is the mapped handler methods. Use "*" for all methods. |
| allowedHeaders |
List<String> |
Request headers the client may send. Default is "*". Preflight responses include these in Access-Control-Allow-Headers. |
| exposedHeaders |
List<String> |
Response headers the browser should expose to client-side JavaScript. Defaults to empty (only CORS-safelisted headers exposed). |
| allowCredentials |
Boolean |
Whether to include cookies and Authorization headers. When true, allowedOrigins must not be "*". |
| maxAge |
Long |
Duration in seconds for which the preflight response is cached by the browser. Default: 1800 (30 minutes). Max varies by browser (Chrome caps at 2 hours). |
allowedOriginPatterns Example
CorsConfiguration config = new CorsConfiguration();
// Allow any subdomain of example.com over HTTPS
config.setAllowedOriginPatterns(List.of("https://*.example.com"));
// Allow localhost on any port (useful during development)
config.setAllowedOriginPatterns(List.of("http://localhost:*"));
// Combine patterns
config.setAllowedOriginPatterns(List.of(
"https://*.example.com",
"http://localhost:*"
));
config.setAllowCredentials(true); // works with patterns, unlike origins="*"
A preflight request is an HTTP OPTIONS request the browser sends automatically before the actual request, when the request is not "simple." The server must respond with the correct CORS headers or the browser will block the actual request.
Anatomy of a Preflight Exchange
# 1. Browser sends OPTIONS preflight
OPTIONS /api/products HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Authorization, Content-Type
# 2. Server responds with 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: Authorization, Content-Type
Access-Control-Max-Age: 3600
Content-Length: 0
# 3. Browser sends actual request (only if preflight passed)
POST /api/products HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json
{"name": "Widget", "price": 29.99}
Caching Preflight Responses
The Access-Control-Max-Age header tells the browser how long to cache the preflight result. During this window, the browser skips the OPTIONS request for the same URL, method, and headers combination.
- Chrome caps max-age at 7200 seconds (2 hours)
- Firefox caps at 86400 seconds (24 hours)
- Set a reasonable value like 3600 (1 hour) for production
- Use 0 during development to disable caching and always see preflight requests in DevTools
Debugging Preflight Issues
Open Chrome DevTools, go to the Network tab, and filter by method:OPTIONS. Check the response headers to verify the server is returning the correct CORS headers. If the preflight fails, the actual request will not appear in the network tab at all.
Spring Boot automatically handles OPTIONS preflight requests when CORS is configured. You do not need to write explicit @RequestMapping(method = RequestMethod.OPTIONS) handlers.
CORS errors show up in the browser console, not in server logs. Here are the most common issues and how to fix them.
1. "No 'Access-Control-Allow-Origin' header is present"
Cause: The server did not return the Access-Control-Allow-Origin header. This happens when no CORS configuration matches the request path or the requesting origin is not in the allowed list.
- Verify the origin in the browser request matches exactly what is configured (scheme + host + port)
- Check that the path pattern in addMapping() or registerCorsConfiguration() covers the requested URL
- If using Spring Security, ensure .cors() is enabled in SecurityFilterChain
2. "Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'"
Cause: You have set allowCredentials(true) but used allowedOrigins("*").
- Replace allowedOrigins("*") with specific origins
- Or use allowedOriginPatterns("*") which supports credentials
3. "Method PUT is not allowed by Access-Control-Allow-Methods"
Cause: The HTTP method used is not included in the server's allowedMethods list.
- Add the missing method: .allowedMethods("GET", "POST", "PUT", "DELETE")
4. "Request header field Authorization is not allowed"
Cause: The Authorization header is not listed in allowedHeaders.
- Add it explicitly: .allowedHeaders("Authorization", "Content-Type")
- Or use .allowedHeaders("*") to allow all headers
5. Preflight Returns 401/403
Cause: Spring Security is intercepting the OPTIONS request and requiring authentication before CORS headers are sent.
- Ensure .cors() is configured in the security filter chain — it places the CorsFilter before authentication filters
- Avoid manually permitting OPTIONS in authorizeHttpRequests — let the CORS filter handle it
6. CORS Works Locally but Fails in Production
- Check if a reverse proxy (Nginx, CloudFront) is stripping or overwriting CORS headers
- Verify the production origin URL matches exactly (trailing slashes, www vs non-www)
- Ensure the proxy forwards the Origin header to the backend
Never Use Wildcard Origins in Production
Using allowedOrigins("*") in production exposes your API to requests from any website. Always specify exact origins or use allowedOriginPatterns to restrict to known domains.
// BAD — allows any website to call your API
config.setAllowedOrigins(List.of("*"));
// GOOD — only your frontend domains
config.setAllowedOrigins(List.of(
"https://app.example.com",
"https://admin.example.com"
));
// GOOD — all subdomains of your domain
config.setAllowedOriginPatterns(List.of("https://*.example.com"));
Use Environment-Specific Configuration
Externalize CORS origins via Spring profiles so development and production have different allowed origins.
@Configuration
public class CorsConfig {
@Bean
@Profile("dev")
public CorsConfigurationSource devCorsSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("http://localhost:*"));
config.setAllowedMethods(List.of("*"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@Bean
@Profile("prod")
public CorsConfigurationSource prodCorsSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setExposedHeaders(List.of("X-Total-Count"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
Restrict Allowed Methods and Headers
Only allow the HTTP methods and headers your API actually uses. Avoid allowedMethods("*") and allowedHeaders("*") in production — they expand the attack surface unnecessarily.
Set a Reasonable maxAge
A maxAge of 3600 (1 hour) is a good production default. It reduces preflight overhead without making configuration changes slow to propagate.
Coordinate with Reverse Proxies
If Nginx or a CDN sits in front of your Spring Boot app, make sure CORS headers are set in only one place. Having both the proxy and the app set CORS headers can cause duplicate headers, which browsers reject.
# Nginx — pass through CORS headers from Spring Boot, do NOT add your own
location /api/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header Origin $http_origin;
# Do NOT add add_header Access-Control-* here if Spring handles CORS
}
Security Checklist
- Never use allowedOrigins("*") with allowCredentials(true) — it is rejected by browsers and exposes cookies to all sites
- Keep exposedHeaders minimal — only expose headers your frontend actually reads
- Use HTTPS for all allowed origins in production
- Do not rely on CORS as your only security mechanism — it is a browser-enforced policy and does not protect against server-to-server requests
- Review CORS configuration as part of your security audit process
- Test CORS with curl -H "Origin: https://evil.com" -v to verify unauthorized origins are blocked