CORS in Spring Boot looks easy right up until the browser starts throwing vague errors and your API “works in Postman” but fails in Chrome.
That’s the normal path.
Spring Boot gives you a few different places to configure CORS, and that flexibility is exactly why teams end up with broken preflight requests, duplicated headers, or insecure wildcard rules in production. I’ve seen all three.
This guide covers how CORS actually works in a Spring Boot app, when to use each configuration style, and the mistakes that tend to waste the most time.
What CORS is actually doing
CORS stands for Cross-Origin Resource Sharing. It’s a browser-enforced policy that controls whether JavaScript running on one origin can read responses from another origin.
An origin is:
- scheme
- host
- port
So these are different origins:
https://app.example.comhttps://api.example.comhttp://app.example.comhttps://app.example.com:8443
If your frontend runs on https://app.example.com and calls https://api.example.com, the browser checks the API response for CORS headers.
A very common one is:
Access-Control-Allow-Origin: https://app.example.com
Some public APIs allow any origin:
Access-Control-Allow-Origin: *
A real example from api.github.com:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning
That second header matters because browsers only expose a limited set of response headers to JavaScript unless you explicitly list them with Access-Control-Expose-Headers.
Simple requests vs preflight requests
Browsers don’t always send CORS requests the same way.
Simple request
A simple cross-origin GET may go straight to the API, and the browser checks the response headers after the fact.
Example:
fetch("https://api.example.com/public")
.then(r => r.json())
.then(console.log);
Preflight request
If the browser sees something more sensitive, it sends an OPTIONS request first. That’s the preflight.
Typical triggers:
- method is
PUT,PATCH, orDELETE - custom headers like
Authorization - content type like
application/jsonin some contexts that trigger preflight with fetch and custom headers combinations
Example frontend call:
fetch("https://api.example.com/users/42", {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer token"
},
body: JSON.stringify({ name: "Alice" })
});
The browser may first send:
OPTIONS /users/42
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
Your Spring Boot app must answer with something like:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
If that response is wrong, the browser blocks the real request.
Option 1: @CrossOrigin on a controller
This is the fastest way to enable CORS for one controller or one endpoint.
package com.example.demo.web;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@CrossOrigin(
origins = "https://app.example.com",
allowedHeaders = {"Authorization", "Content-Type"},
methods = {}
)
public class HelloController {
@GetMapping("/api/hello")
public String hello() {
return "hello";
}
}
You can also apply it to a single method:
@GetMapping("/api/public")
@CrossOrigin(origins = "*")
public String publicData() {
return "public";
}
When I use @CrossOrigin
- small apps
- demos
- one-off endpoints
- temporary debugging
When I avoid it
- larger APIs
- multiple environments
- apps using Spring Security
- anything where rules should be centralized
Scattering CORS rules across controllers gets messy fast.
Option 2: Global CORS with WebMvcConfigurer
This is usually the cleanest choice for a regular Spring MVC API.
package com.example.demo.config;
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")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("Authorization", "Content-Type")
.exposedHeaders("ETag", "Link", "X-RateLimit-Remaining")
.allowCredentials(true)
.maxAge(3600);
}
}
What each piece does:
addMapping("/api/**")applies CORS only to those routesallowedOrigins(...)lists trusted originsallowedMethods(...)controls what browsers may send cross-originallowedHeaders(...)controls request headers allowed in preflightexposedHeaders(...)lets frontend JavaScript read those response headersallowCredentials(true)allows cookies or HTTP authmaxAge(3600)caches preflight results for an hour
Big rule: don’t mix allowCredentials(true) with *
This is one of the most common CORS mistakes.
Bad:
.allowedOrigins("*")
.allowCredentials(true)
Browsers reject that combination. If credentials are allowed, you must return a specific origin, not a wildcard.
If you need flexible matching for subdomains, use origin patterns.
Option 3: allowedOriginPatterns for subdomains
For multi-tenant or environment-based setups, exact origins can be too rigid.
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("https://*.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
}
}
This is much better than hardcoding ten different subdomains.
Still, be careful. https://*.example.com is reasonable. * is not.
Spring Security changes the story
If your app uses Spring Security, CORS must be wired into the security filter chain too. Otherwise preflight OPTIONS requests often get blocked before MVC CORS config even runs.
This is the version people forget.
package com.example.demo.config;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setExposedHeaders(List.of("ETag", "Link", "X-RateLimit-Remaining"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
If you use Spring Security, I prefer this centralized approach over splitting config between MVC and security. Fewer surprises.
Exposing headers to frontend code
A response header being present does not mean browser JavaScript can read it.
For example:
config.setExposedHeaders(List.of("ETag", "Link", "X-RateLimit-Remaining"));
Then frontend code can do:
const response = await fetch("https://api.example.com/repos");
console.log(response.headers.get("ETag"));
console.log(response.headers.get("X-RateLimit-Remaining"));
Without Access-Control-Expose-Headers, those values may be invisible to browser code even though DevTools shows them.
GitHub’s API is a good real-world example here. It exposes many operational headers:
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning
That’s a solid pattern for APIs where clients need pagination, caching, or rate-limit metadata.
Development setup for localhost
A very normal setup is:
- frontend:
http://localhost:5173 - backend:
http://localhost:8080
That is cross-origin because the ports differ.
Typical dev config:
config.setAllowedOrigins(List.of(
"http://localhost:3000",
"http://localhost:5173"
));
I usually keep localhost origins explicit instead of using wildcards. It prevents lazy config from leaking into production.
Common CORS bugs in Spring Boot
1. Preflight gets 401 or 403
Usually Spring Security is blocking OPTIONS.
Fix: enable http.cors() and provide a CorsConfigurationSource.
2. You configured CORS twice
Example: @CrossOrigin plus WebMvcConfigurer plus security CORS config.
That can create conflicting behavior and painful debugging. Pick one centralized strategy when possible.
3. Wildcard with credentials
Again, this fails in browsers:
config.setAllowedOrigins(List.of("*"));
config.setAllowCredentials(true);
Use explicit origins or allowedOriginPatterns.
4. Missing allowed headers
If the browser sends:
Access-Control-Request-Headers: authorization, content-type
and your server doesn’t allow them, preflight fails.
5. CORS confused with authentication
CORS does not protect your API from server-to-server abuse. It only controls what browsers let frontend JavaScript read. Curl, mobile apps, and backend services don’t care about CORS.
Treat CORS as a browser policy, not an auth mechanism.
Testing CORS from the command line
You can simulate a preflight request with curl:
curl -i -X OPTIONS http://localhost:8080/api/users/42 \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: Authorization, Content-Type"
You want to see headers like:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
And for a real request:
curl -i http://localhost:8080/api/hello \
-H "Origin: https://app.example.com"
If CORS is configured correctly, the response should include:
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
A secure default I actually recommend
For most production Spring Boot APIs:
- scope CORS to
/api/** - allow only known frontend origins
- allow only required methods
- allow only required headers
- expose only headers clients need
- enable credentials only when necessary
- configure CORS in Spring Security if security is enabled
Example:
@Bean
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"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setExposedHeaders(List.of("ETag", "Link"));
config.setAllowCredentials(true);
config.setMaxAge(1800L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
That’s boring, which is exactly what you want from CORS.
For the official Spring reference, check the Spring Framework and Spring Security documentation:
- https://docs.spring.io/spring-framework/reference/web/webmvc-cors.html
- https://docs.spring.io/spring-security/reference/servlet/integrations/cors.html
If you’re also tightening other browser-side protections, headers like CSP, HSTS, and friends matter too. For CSP specifically, I like https://csp-guide.com as a practical reference.