When building a frontend with React or Vue and connecting it to a Spring Boot REST API, you will almost certainly run into a CORS error at some point.

Access to XMLHttpRequest at 'http://localhost:8080/api/users'
from origin 'http://localhost:3000' has been blocked by CORS policy

Have you ever added @CrossOrigin only to find it had no effect, or introduced Spring Security and suddenly had your CORS configuration ignored? This article walks through three configuration patterns in order and covers the pitfalls that come with adding Spring Security.

What Is CORS — How the Browser Produces the Error

CORS stands for Cross-Origin Resource Sharing. It is the mechanism that safely relaxes the browser’s security feature known as the same-origin policy.

An origin is the combination of protocol + host + port. Because http://localhost:3000 and http://localhost:8080 differ in port, they are treated as different origins.

Before sending a request that includes a POST or custom headers, the browser first issues 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 with curl or Postman but fails in the browser. Configuring CORS means ensuring the server returns the correct response headers.

Pattern 1: The @CrossOrigin Annotation

This is the simplest approach. Just annotate your controller directly and it works.

@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = "http://localhost:3000")
public class UserController {

    @GetMapping
    public List<User> getUsers() { ... }

    // To allow a different origin for only this endpoint
    @PostMapping
    @CrossOrigin(origins = "https://app.example.com")
    public User createUser(@RequestBody UserRequest req) { ... }
}

It is convenient, but as the number of endpoints grows it is easy to forget to add the annotation. Consider this option for cases without Spring Security, early prototyping, or when you only need to allow specific endpoints.

Pattern 2: Global Configuration with WebMvcConfigurer

To apply CORS across 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 entire application. To split configuration by path, write something like addMapping("/api/**").

In environments without Spring Security, this is all you need. However, once you add Spring Security, this configuration will suddenly stop working.

Why CORS Configuration Stops Working After Adding Spring Security

This is where many developers get stuck.

Spring Security operates in the filter layer, before the DispatcherServlet. The CORS configuration in WebMvcConfigurer is applied in the Spring MVC layer after the DispatcherServlet, so the SecurityFilterChain intercepts requests first.

When a Preflight OPTIONS request arrives, the SecurityFilterChain treats it as an unauthenticated request and returns a 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 SecurityFilterChain

The following 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 also makes it easy to share the configuration with WebMvcConfigurer in the future, improving maintainability. If you are using Spring Security 5.x (WebSecurityConfigurerAdapter), the syntax differs, but in a Spring Boot 3.x environment the format above is the standard.

The Difference Between allowedOrigins and allowedOriginPatterns

This is a common issue in production environments.

If you set allowCredentials(true) while also specifying allowedOrigins("*"), Spring will throw an error. This is because the CORS specification prohibits wildcard origins for requests that include credentials.

Use allowedOriginPatterns instead, which supports wildcards.

// Can be combined with allowCredentials(true)
config.setAllowedOriginPatterns(List.of("https://*.example.com"));

// In development, * is acceptable
config.setAllowedOriginPatterns(List.of("*"));

In production, explicitly specifying the allowed origins is the safer choice. If you want to switch configuration per environment, Switching Environment-Specific Configuration with Spring Profiles is a useful reference.

Verifying Preflight with curl

You can verify that your 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, the configuration is working. If it does not, the configuration has not been applied correctly. When Spring Security is in use, you can also use this command to check whether the OPTIONS request is returning a 401.

Common mistakes to watch for include combining allowCredentials(true) with allowedOrigins("*"), and configuring only WebMvcConfigurer without adding cors() to the SecurityFilterChain. If your configuration is not taking effect, start by verifying behavior with this curl command.

Summary

Here is a quick guide for choosing among the three patterns.

SituationRecommended Pattern
No Spring Security, specific endpoints only@CrossOrigin
No Spring Security, apply globallyWebMvcConfigurer
With Spring Security (production)SecurityFilterChain + CorsConfigurationSource

If you are using Spring Security, Pattern 3 is essentially always the right choice. Extracting CorsConfigurationSource as a Bean also makes centralized configuration management easier.

For implementing authentication and authorization, see the Spring Security JWT article. If you want to understand the internals of Filter and Interceptor in more depth, The Difference Between Interceptor and Filter and When to Use Each is also worth reading.