When you build a frontend with React or Vue and try to connect it to a Spring Boot REST API, you will almost certainly run into a CORS error.
Access to XMLHttpRequest at 'http://localhost:8080/api/users'
from origin 'http://localhost:3000' has been blocked by CORS policy
Have you ever thought “I added @CrossOrigin but it doesn’t work” or “my configuration was suddenly ignored after I added Spring Security”? This article walks through three configuration patterns in order and also covers the pitfalls you hit when introducing Spring Security.
What Is CORS - How the Browser Produces the Error
CORS stands for Cross-Origin Resource Sharing. It is a mechanism for safely relaxing the same-origin policy, a security feature of the browser.
An origin is the combination of protocol + host + port. http://localhost:3000 and http://localhost:8080 have different ports, so they are different origins.
Before sending a request that uses POST or includes custom headers, the browser sends a preflight request using the OPTIONS method. If the server does not return headers such as Access-Control-Allow-Origin, the browser blocks the request.
This is why the same request succeeds in curl or Postman but fails only in the browser. Handling CORS means configuring the server to return the correct response headers.
Pattern 1: The @CrossOrigin Annotation
This is the simplest approach. Just add it directly to the controller and it works.
@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = "http://localhost:3000")
public class UserController {
@GetMapping
public List<User> getUsers() { ... }
// このエンドポイントだけ別のオリジンを許可したい場合
@PostMapping
@CrossOrigin(origins = "https://app.example.com")
public User createUser(@RequestBody UserRequest req) { ... }
}
It is convenient, but it is easy to forget to add the annotation as endpoints grow. Think of it as the option for cases without Spring Security, for prototyping, or when you only want to allow specific endpoints.
Pattern 2: Global Configuration with WebMvcConfigurer
To apply CORS to all endpoints at once, use WebMvcConfigurer.
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
addMapping("/**") applies the configuration to the whole application. If you want different settings per path, you can write something like addMapping("/api/**").
In an environment without Spring Security, this is all you need. However, once you introduce Spring Security, this configuration suddenly stops working.
Why CORS Configuration Stops Working When You Add Spring Security
This is where many people get stuck.
Spring Security operates in the Filter layer in front of the DispatcherServlet. The CORS configuration in WebMvcConfigurer applies to the Spring MVC layer after the DispatcherServlet, so the SecurityFilterChain catches the request first.
When a preflight OPTIONS request arrives, the SecurityFilterChain treats it as an “unauthenticated request” and returns 401, which the browser interprets as a CORS error. The request never even reaches the WebMvcConfigurer configuration.
The solution is to make the SecurityFilterChain aware of CORS as well.
Pattern 3: Configuring CORS in the SecurityFilterChain
This is the approach for Spring Security 6.x.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3000"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
Defining CorsConfigurationSource as a Bean makes it easier to manage, and you can later share the configuration with WebMvcConfigurer. If you are using Spring Security 5.x (WebSecurityConfigurerAdapter), the syntax differs, but in a Spring Boot 3.x environment the form above is the standard.
The Difference Between allowedOrigins and allowedOriginPatterns
This frequently becomes an issue in production.
If you set allowCredentials(true) and also specify allowedOrigins("*"), Spring throws an error. The CORS specification forbids wildcard origins for requests that include credentials.
Use allowedOriginPatterns instead, which does allow wildcards.
// allowCredentials(true)と組み合わせて使える
config.setAllowedOriginPatterns(List.of("https://*.example.com"));
// 開発環境では * でも可
config.setAllowedOriginPatterns(List.of("*"));
In production, it is safer to specify concrete origins explicitly. If you want to switch settings per environment, see Switching Configuration with Spring Profiles.
Pattern 4: Registering CorsFilter as a Bean
There is actually another approach, separate from both the Spring MVC configuration and the SecurityFilterChain: registering a CorsFilter as a Bean.
@Configuration
public class CorsFilterConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3000"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
When Spring Boot finds a Bean of type Filter, it automatically registers it as a servlet Filter, so this alone applies global CORS configuration to the entire application. Because it responds to preflight requests before the DispatcherServlet, it has the advantage of working even in setups where servlets other than Spring MVC coexist.
However, you need to be careful when combining it with Spring Security. A CorsFilter registered as a Bean gets the lowest precedence by default, so it is ordered after the SecurityFilterChain. In other words, even with this approach, unless you enable cors() on the SecurityFilterChain side as in Pattern 3, preflight requests get stopped by the SecurityFilterChain. When Spring Security finds a CorsFilter Bean named corsFilter, it uses that Filter directly inside cors().
In conclusion, if you use Spring Security, go straight with Pattern 3. If you do not, pick just one of WebMvcConfigurer or this CorsFilter Bean. Writing both will not break anything, but it duplicates configuration management, so stick to one.
Auto-Detecting the CorsConfigurationSource Bean with cors(Customizer.withDefaults())
In Pattern 3 we passed the source explicitly with cors(cors -> cors.configurationSource(...)), but if you define a CorsConfigurationSource Bean named corsConfigurationSource, simply writing cors(Customizer.withDefaults()) picks it up automatically.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults()) // corsConfigurationSource Beanを自動検出
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
If the Bean name differs, it will not be detected, so it is safest to keep the method name as corsConfigurationSource. Conversely, if you define the Bean but forget to call cors() itself, you end up with “I configured it, but OPTIONS still returns 401”. If preflight returns 401, first check whether cors() is present.
Forgetting exposedHeaders Means the Frontend Cannot Read Response Headers
A surprisingly common problem after CORS starts working is “I can’t get the response headers from JavaScript”.
Under the CORS specification, the response headers the browser exposes to JavaScript are limited to a small set such as Content-Type and Cache-Control. To read custom headers like Authorization, Location, or X-Total-Count via response.headers.get() in fetch, the server must expose them explicitly.
config.setExposedHeaders(List.of("Authorization", "Location", "X-Total-Count"));
With WebMvcConfigurer, write .exposedHeaders("Authorization", "Location"). If you return a JWT in a response header, or want to use the Location from a 201 Created on the frontend, be aware that without this setting you will only ever get null.
Verifying Preflight with curl
You can check whether the configuration is applied correctly using curl.
curl -v -X OPTIONS http://localhost:8080/api/users \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type"
If the response includes Access-Control-Allow-Origin: http://localhost:3000, you are good. If it does not, the configuration is not applied correctly. If you are using Spring Security, this is also where you can check whether the OPTIONS request is returning 401.
Common mistakes include combining allowCredentials(true) with allowedOrigins("*"), and forgetting to write cors() in the SecurityFilterChain while only configuring WebMvcConfigurer. If the configuration does not seem to take effect, start by verifying the behavior with this curl command.
There are also cases where the happy path works but CORS headers are missing only on error responses. The typical cause is that a Filter running before CORS processing, such as an authentication Filter, throws an exception and the response is returned as-is. For exceptions at the controller layer, catching them with @RestControllerAdvice and returning them as a normal response ensures the browser receives an error response with CORS headers attached. How to build a production-ready exception handler is covered in Implementing GlobalExceptionHandler for Production.
Summary
Here is a summary of when to use each of the four patterns.
| Situation | Recommended Pattern |
|---|---|
| No Spring Security, specific endpoints only | @CrossOrigin |
| No Spring Security, apply globally | WebMvcConfigurer |
| Spring Security present (production) | SecurityFilterChain + CorsConfigurationSource |
| Servlets other than Spring MVC coexist | CorsFilter Bean |
If you are using Spring Security, Pattern 3 is essentially the only choice. Extracting CorsConfigurationSource as a Bean also makes it easier to manage the configuration in one place.
For implementing authentication and authorization, also see the Spring Security JWT article. If you want to understand how Filters and Interceptors work in more detail, The Difference Between Interceptor and Filter and When to Use Each is also a helpful reference.
References
The code in this article has been verified with Spring Boot 3.x / Spring Security 6.x. For more detailed specifications, refer to the official documentation below.