How to Fix "Blocked by CORS Policy" in Spring Boot
Introduction
A front end running onhttp://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.
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 anAuthorization 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.| Option | When to use it | Limit |
|---|---|---|
@CrossOrigin on a controller or method | One or two endpoints, no Spring Security | Ignored for preflight requests that Spring Security rejects |
WebMvcConfigurer.addCorsMappings() | Rules for many paths in one place, no Spring Security | Same 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 Security | None 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());
}
}
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"]
> 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
- The first attempt - the preflight request gets
401with aWWW-Authenticateheader and noAccess-Control-Allow-Originheader. That missing header is exactly what the browser reports. The realGETwould 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
200withAccess-Control-Allow-Origin,Access-Control-Allow-MethodsandAccess-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.examplegets403andInvalid CORS request. CORS is still enforced; it is just enforced in the right place now.
Pitfalls
- Permitting all
OPTIONSrequests in the security rules is a common workaround, not a fix. It opens everyOPTIONSrequest 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 aCorsConfigurationSourcebean, Spring Security uses Spring MVC's CORS configuration. allowedOrigins("*")cannot be combined withsetAllowCredentials(true), which cookies need; list the origins, or useallowedOriginPatterns.- The origin must match exactly, including the scheme and port:
http://localhost:3000andhttp://127.0.0.1:3000are 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.Related Spring Boot tutorials
CORS, Content Negotiation and File Uploads in Spring BootClick to read tutorial: CORS, Content Negotiation and File Uploads in Spring Boot Spring Security CSRF, CORS and Security Headers ExplainedClick to read tutorial: Spring Security CSRF, CORS and Security Headers Explained Configuring SecurityFilterChain in Spring SecurityClick to read tutorial: Configuring SecurityFilterChain in Spring Security
CORS, Content Negotiation and File Uploads in Spring BootClick to read tutorial: CORS, Content Negotiation and File Uploads in Spring Boot Spring Security CSRF, CORS and Security Headers ExplainedClick to read tutorial: Spring Security CSRF, CORS and Security Headers Explained Configuring SecurityFilterChain in Spring SecurityClick to read tutorial: Configuring SecurityFilterChain in Spring Security