How to Fix "Blocked by CORS Policy" in Spring Boot

Introduction

A front end running on http://localhost:3000 calls a Spring Boot API on http://localhost:8080, and the call never arrives. Chrome's console shows a message like this one:
Access to fetch at 'http://localhost:8080/api/books' from origin 'http://localhost:3000' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Often a CORS mapping is already configured, and the error stays. This quick tip shows why that happens when Spring Security is in the application, and the configuration that fixes it.

Tested on Spring Boot 4.1.2, Spring Framework 7.0.10, Spring Security 7.1.2 and JDK 25.

Letting Spring Security answer the preflight request

Before a cross-origin request that carries an Authorization header, the browser sends a preflight request: an OPTIONS request without credentials, asking whether the real request is allowed. With Spring Security in the application, the preflight request reaches the security filter chain first, and an unauthenticated OPTIONS request is rejected with 401 before any CORS mapping of Spring MVC is consulted. The fix is to give the CORS rules to Spring Security: define a CorsConfigurationSource bean and switch CORS on in the filter chain -
The short answer
@Bean
CorsConfigurationSource corsConfigurationSource() {
  CorsConfiguration config = new CorsConfiguration();
  config.setAllowedOrigins(List.of("http://localhost:3000"));
  config.setAllowedMethods(List.of("GET", "POST"));
  config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
  UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
  source.registerCorsConfiguration("/api/**", config);
  return source;
}

// in the SecurityFilterChain bean
http.cors(withDefaults());

Where CORS rules can be configured

Spring offers three places for CORS rules. Only the last one is applied before Spring Security decides about the request.
OptionWhen to use itLimit
@CrossOrigin on a controller or methodOne or two endpoints, no Spring SecurityIgnored for preflight requests that Spring Security rejects
WebMvcConfigurer.addCorsMappings()Rules for many paths in one place, no Spring SecuritySame as above, unless the filter chain calls http.cors(withDefaults()), which then uses these mappings
CorsConfigurationSource bean with http.cors(withDefaults())Any application with Spring SecurityNone for this problem - the rules apply in the filter chain and in Spring MVC

The preflight request before and after the fix

The example is a small API with one endpoint and HTTP Basic authentication. Two configurations are selected by a profile: problem is the usual first attempt - a Spring MVC CORS mapping and a filter chain without CORS - and the default profile is the fix.
src/main/resources/application.properties
spring.main.banner-mode=off
logging.level.root=WARN
BookApp.java
//BookApp.java
package com.javabrahman.tips.cors;

import java.util.List;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@RestController
public class BookApp {

  @GetMapping("/api/books")
  public List<String> books() {
    return List.of("Effective Java", "Clean Code");
  }

  public static void main(String[] args) {
    SpringApplication.run(BookApp.class, args);
  }
}
ProblemConfig.java - the first attempt
//ProblemConfig.java
package com.javabrahman.tips.cors;

import static org.springframework.security.config.Customizer.withDefaults;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@Profile("problem")
public class ProblemConfig implements WebMvcConfigurer {

  @Override
  public void addCorsMappings(CorsRegistry registry) {
    registry.addMapping("/api/**")
        .allowedOrigins("http://localhost:3000")
        .allowedMethods("GET", "POST")
        .allowedHeaders("Authorization", "Content-Type");
  }

  @Bean
  SecurityFilterChain api(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .httpBasic(withDefaults());
    return http.build();
  }
}
SecurityConfig.java - the fix
//SecurityConfig.java
package com.javabrahman.tips.cors;

import static org.springframework.security.config.Customizer.withDefaults;

import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

@Configuration
@Profile("!problem")
public class SecurityConfig {

  @Bean
  CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("http://localhost:3000"));
    config.setAllowedMethods(List.of("GET", "POST"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
  }

  @Bean
  SecurityFilterChain api(HttpSecurity http) throws Exception {
    http.cors(withDefaults())
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .httpBasic(withDefaults());
    return http.build();
  }
}
Users.java
//Users.java
package com.javabrahman.tips.cors;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class Users {

  @Bean
  UserDetailsService userDetailsService() {
    return new InMemoryUserDetailsManager(User.withUsername("ann").password("{noop}secret").roles("USER").build());
  }
}
The requests below are what the browser sends: first the preflight, with the Origin of the front end and the method and header it wants to use, then - if the preflight succeeds - the real request with the credentials of the user ann.  OUTPUT with the first attempt (profile problem)
> curl -i -X OPTIONS http://localhost:8080/api/books -H "Origin: http://localhost:3000" -H "Access-Control-Request-Method: GET" -H "Access-Control-Request-Headers: authorization"
HTTP/1.1 401
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
Set-Cookie: JSESSIONID=D2FA9C2CBF42464FC9424D1A08F47770; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0
Expires: 0

> curl -i http://localhost:8080/api/books -H "Origin: http://localhost:3000" -H "Authorization: Basic YW5uOnNlY3JldA=="
HTTP/1.1 200
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
Access-Control-Allow-Origin: http://localhost:3000
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
Content-Length: 31
Content-Type: application/json
Expires: 0

["Effective Java","Clean Code"]
 OUTPUT with the fix (default profile)
> curl -i -X OPTIONS http://localhost:8080/api/books -H "Origin: http://localhost:3000" -H "Access-Control-Request-Method: GET" -H "Access-Control-Request-Headers: authorization"
HTTP/1.1 200
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET,POST
Access-Control-Allow-Headers: authorization
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
Content-Length: 0
Expires: 0

> curl -i http://localhost:8080/api/books -H "Origin: http://localhost:3000" -H "Authorization: Basic YW5uOnNlY3JldA=="
HTTP/1.1 200
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
Access-Control-Allow-Origin: http://localhost:3000
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
Content-Length: 31
Content-Type: application/json
Expires: 0

["Effective Java","Clean Code"]

> curl -i -X OPTIONS http://localhost:8080/api/books -H "Origin: http://evil.example" -H "Access-Control-Request-Method: GET"
HTTP/1.1 403
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Pragma: no-cache
X-Frame-Options: DENY
Cache-Control: no-store, must-revalidate, no-cache, max-age=0
Expires: 0

Invalid CORS request
Explanation of the output
  • The first attempt - the preflight request gets 401 with a WWW-Authenticate header and no Access-Control-Allow-Origin header. That missing header is exactly what the browser reports. The real GET would have worked - Spring MVC adds the CORS header to it - but a browser never sends it after a failed preflight.
  • The fix - the same preflight request gets 200 with Access-Control-Allow-Origin, Access-Control-Allow-Methods and Access-Control-Allow-Headers, so the browser goes on to send the real request, which returns the books.
  • An origin that is not allowed - http://evil.example gets 403 and Invalid CORS request. CORS is still enforced; it is just enforced in the right place now.

Pitfalls

  • Permitting all OPTIONS requests in the security rules is a common workaround, not a fix. It opens every OPTIONS request to anonymous callers and keeps the CORS decision outside Spring Security; http.cors() is the way Spring Security supports.
  • If the rules already live in a WebMvcConfigurer, http.cors(withDefaults()) is enough: without a CorsConfigurationSource bean, Spring Security uses Spring MVC's CORS configuration.
  • allowedOrigins("*") cannot be combined with setAllowCredentials(true), which cookies need; list the origins, or use allowedOriginPatterns.
  • The origin must match exactly, including the scheme and port: http://localhost:3000 and http://127.0.0.1:3000 are different origins.
  • CORS is enforced by browsers only. curl, Postman and server-to-server calls ignore it, which is why the API "works in Postman" while the browser call fails.

Related Spring Boot tutorials

How CORS works, and the Spring MVC options in detail, are covered in CORS, content negotiation and file uploadClick to read tutorial: CORS, Content Negotiation and File Uploads in Spring Boot; how Spring Security handles CORS together with CSRF and security headers is in CSRF, CORS and security headersClick to read tutorial: Spring Security CSRF, CORS and Security Headers Explained.