Your React or Angular app calls the Spring Boot API and the browser console shows:
Access to fetch at 'http://localhost:8080/api/orders' from origin 'http://localhost:5173'
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.The same request works in Postman and with curl. That is the clue: CORS is a browser rule. The page at http://localhost:5173 is a different origin (scheme, host and port) from the API at http://localhost:8080, so the browser only hands the response to your JavaScript if the server’s response explicitly allows that origin. Here the server never sent Access-Control-Allow-Origin, either because no CORS configuration exists or because something rejected the request before CORS handling ran. If you need the browser side of this story first, read what the browser checks when it blocks a request.
Quick fix checklist
- Configure CORS once, globally, with
WebMvcConfigurer#addCorsMappingsor aCorsConfigurationSourcebean. - If Spring Security is on the classpath, add
http.cors(Customizer.withDefaults())to yourSecurityFilterChain. - Allow the exact frontend origin, including port:
http://localhost:5173, notlocalhost:5173. - Using cookies or
credentials: 'include'? SetallowCredentials(true)and list origins explicitly (or useallowedOriginPatterns). - Allow the request headers you send (
Authorization,Content-Type) and expose response headers you read (Location). - Check the preflight in the Network tab: an
OPTIONSreturning 401 or 403 means security rejected it.
Before you start
You should know what an HTTP header is and be able to open the browser’s developer tools, Network tab. This article targets Spring Boot 3.x with Spring MVC and, where noted, Spring Security 6 using SecurityFilterChain beans and the lambda DSL.
Why it happens
The browser sends a cross-origin request in one of two ways. Simple requests (a GET with no custom headers, for example) go straight to the server, and the browser checks Access-Control-Allow-Origin on the response. Anything else, such as PUT, DELETE, a JSON Content-Type or an Authorization header, triggers a preflight: an OPTIONS request carrying Origin, Access-Control-Request-Method and Access-Control-Request-Headers. The server must answer with matching Access-Control-Allow-* headers, or the real request is never sent.
In Spring MVC, CORS rules attached through @CrossOrigin or addCorsMappings are applied by the handler mapping, after the request reaches DispatcherServlet. Spring Security, however, is a servlet filter that runs before DispatcherServlet. The preflight carries no credentials (browsers never send cookies or Authorization on a preflight), so an authenticated API answers it with 401 or 403 before MVC’s CORS logic ever runs. No CORS headers on that rejection means the browser reports a CORS failure, even though the real cause is authentication.
http.cors(...) fixes the ordering by adding Spring’s CorsFilter near the start of the security filter chain. It answers preflights itself and adds headers to actual responses. It looks for a CorsConfigurationSource bean, and if there is none and Spring MVC is present, it reuses the MVC CORS configuration.
When Spring rejects an origin that is not allowed, it returns 403 with the body Invalid CORS request. Seeing that body means CORS configuration exists but the origin, method or header did not match.
Step-by-step walkthrough
Step 1: Reproduce the preflight with curl
Simulate exactly what the browser sends:
curl -i -X OPTIONS http://localhost:8080/api/orders \
-H 'Origin: http://localhost:5173' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,authorization'A correct answer is 200 with Access-Control-Allow-Origin: http://localhost:5173, an Access-Control-Allow-Methods that includes POST, and Access-Control-Allow-Headers listing both headers. A 401/403 without those headers is the security-ordering problem. A 403 with Invalid CORS request is a configuration mismatch.
Step 2: Choose where CORS lives
@CrossOrigin on a controller works for a single endpoint, but scattered annotations drift: a new controller ships without one. Prefer one global definition. Without Spring Security, WebMvcConfigurer is enough:
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 WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173", "https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("Content-Type", "Authorization")
.exposedHeaders("Location")
.allowCredentials(true)
.maxAge(3600);
}
}Step 3: Wire CORS into Spring Security
With Spring Security, define the rules as a CorsConfigurationSource bean and enable CORS in the filter chain:
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
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
public class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:5173", "https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Content-Type", "Authorization"));
config.setExposedHeaders(List.of("Location"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}The JWT resource-server line (from spring-boot-starter-oauth2-resource-server) is just one example of authentication. Keep a single source of truth: if you use this bean, remove addCorsMappings and @CrossOrigin so two configurations cannot disagree.
Step 4: Handle credentials and wildcards correctly
allowCredentials(true) tells the browser it may send cookies and expose the response to credentialed requests. The specification forbids combining it with Access-Control-Allow-Origin: *, and Spring enforces that at request time with an IllegalArgumentException whose message begins When allowCredentials is true, allowedOrigins cannot contain the special value "*". For many subdomains use patterns, which Spring echoes back as the concrete origin:
config.setAllowedOriginPatterns(List.of("https://*.example.com", "http://localhost:[*]"));Step 5: Expose the headers your frontend reads
Even when the request succeeds, JavaScript can read only a few safe response headers. If the frontend reads Location after a 201 Created, or a custom X-Total-Count for pagination, list it in exposedHeaders, otherwise response.headers.get('Location') returns null with no error at all.
Worked scenario
A Vite frontend on http://localhost:5173 authenticates with a JWT and sends it as Authorization: Bearer <token>. The API has public catalogue endpoints and protected order endpoints, and the team handled CORS with annotations:
@RestController
@RequestMapping("/api/public/catalog")
@CrossOrigin(origins = "http://localhost:5173")
class CatalogController { /* ... */ }
@RestController
@RequestMapping("/api/orders")
@CrossOrigin(origins = "http://localhost:5173")
class OrderController { /* ... */ }Catalogue requests work. Every call to /api/orders fails with the preflight message, and the Network tab shows OPTIONS /api/orders returned 401.
Diagnosis: the Authorization header and the JSON Content-Type make the browser send a preflight first. The preflight never includes the Authorization header, the security chain requires authentication for /api/orders, and it answers 401 before @CrossOrigin is ever consulted. The catalogue calls are simple GETs to a permitAll() path, so they reach MVC, where the annotation adds the CORS header. That asymmetry points straight at the security layer.
The fix is the SecurityConfig from Step 3 with http.cors(Customizer.withDefaults()), and both @CrossOrigin annotations deleted. One adjustment: this team uses config.setAllowCredentials(false). A bearer token added by JavaScript is an ordinary request header, permitted through allowedHeaders; CORS “credentials” means cookies and browser-managed HTTP authentication, which this frontend does not use. The preflight now returns 200 from CorsFilter, and the real request carries the token to the controller.
A session-cookie frontend would instead need credentials: 'include' in fetch, allowCredentials(true) and, for writes, a CSRF token.
Common mistake
The most tempting wrong fix is permitting all OPTIONS requests in security:
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()It lets the preflight through to MVC, so it can appear to work, but you now depend on MVC CORS config being present for every path, and any filter that runs before MVC can still answer without CORS headers. http.cors(...) handles preflight at the right layer.
Another is allowedOrigins("*") with allowedHeaders("*") “to get it working”. Without credentials it is acceptable for a truly public API, but it lets every website call it from a user’s browser. With credentials it fails outright.
Finally, disabling CSRF to fix CORS: they are unrelated. A CORS failure is a missing response header; CSRF rejection is a 403 for a missing token on a state-changing request.
Verify the behavior
Test the preflight through the full security chain with MockMvc:
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class CorsPreflightTest {
@Autowired
MockMvc mvc;
@Test
void allowsPreflightFromFrontend() throws Exception {
mvc.perform(options("/api/orders")
.header("Origin", "http://localhost:5173")
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Headers", "content-type"))
.andExpect(status().isOk())
.andExpect(header().string("Access-Control-Allow-Origin", "http://localhost:5173"))
.andExpect(header().string("Access-Control-Allow-Credentials", "true"));
}
@Test
void rejectsUnknownOrigin() throws Exception {
mvc.perform(options("/api/orders")
.header("Origin", "https://evil.example")
.header("Access-Control-Request-Method", "POST"))
.andExpect(status().isForbidden());
}
}In the browser, the preflight row in the Network tab should show status 200 and the Access-Control-Allow-* response headers, followed by the real request.
Interview exercise
“CORS works for GET endpoints but every PUT fails with ‘blocked by CORS policy’ after the team added Spring Security. Postman works fine. Explain why, and what you would change.”
Answer and reasoning
Postman is not a browser, so it ignores CORS; the server is reachable and the problem is the browser’s permission check. GET without custom headers is a simple request and is sent directly, while PUT triggers a preflight OPTIONS. Preflights carry no credentials, so the security filter chain, which runs before Spring MVC, rejects it with 401 or 403 and no CORS headers, and the browser reports that as a CORS error. MVC-level @CrossOrigin or addCorsMappings is never reached. I would enable http.cors(Customizer.withDefaults()) with a single CorsConfigurationSource bean, so the CORS filter answers preflights before authentication, list the exact origins, and enable credentials only if cookies are used. I would prove it with a MockMvc preflight test through the full security chain.