Contents

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
HeaderPurpose
Access-Control-Allow-OriginWhich origins are allowed (e.g. https://app.example.com or *)
Access-Control-Allow-MethodsPermitted HTTP methods (GET, POST, PUT, DELETE, etc.)
Access-Control-Allow-HeadersPermitted request headers (e.g. Authorization, Content-Type)
Access-Control-Allow-CredentialsWhether cookies / authorization headers are allowed (true / false)
Access-Control-Max-AgeHow long (seconds) the browser may cache the preflight response
Access-Control-Expose-HeadersWhich 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
AttributeDefaultDescription
origins*Allowed origins (use originPatterns for wildcards like *.example.com)
methodsThe mapped method(s)Allowed HTTP methods
allowedHeaders*Allowed request headers
exposedHeadersemptyResponse headers exposed to JavaScript
allowCredentialsfalseWhether to allow cookies and auth headers
maxAge1800 (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.

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

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.

2. "Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'"

Cause: You have set allowCredentials(true) but used allowedOrigins("*").

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.

4. "Request header field Authorization is not allowed"

Cause: The Authorization header is not listed in allowedHeaders.

5. Preflight Returns 401/403

Cause: Spring Security is intercepting the OPTIONS request and requiring authentication before CORS headers are sent.

6. CORS Works Locally but Fails in Production
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