Spring Boot
CORS Configuration
CORS errors in a Spring Boot project almost always show up the first time a React, Angular or Vite frontend on localhost:3000 calls an API on localhost:8080, and they are usually fixed by pasting a wildcard until the console goes quiet. This guide explains what the browser is actually checking, the three places Spring lets you configure it (@CrossOrigin, WebMvcConfigurer, Spring Security), how they interact, why preflights fail when Spring Security is on the classpath, how to handle cookies and credentials correctly, and how to test and debug the whole thing with curl and MockMvc.
What CORS Does (and Does Not) Protect
CORS is enforced by the browser, not by your server. Your API receives the request and, in most cases, processes it. What CORS controls is whether the browser lets the calling JavaScript read the response. That has two practical consequences. First, curl, Postman and server-to-server calls ignore CORS completely, so "it works in Postman" tells you nothing about your CORS setup. Second, CORS is not an authentication or authorization mechanism. A permissive policy does not make an endpoint less secure than it already was for non-browser clients, and a strict policy does not protect an endpoint from anyone who can send raw HTTP.
An origin is the combination of scheme, host and port. http://localhost:3000, http://localhost:8080, https://localhost:3000 and http://127.0.0.1:3000 are four different origins. That is why a frontend and backend running on the same machine still trigger CORS, and why a trailing slash or a missing port in your allowed-origins list silently fails to match.
Shell
# The origin the browser sends is scheme + host + port, nothing else.
# Page loaded from: http://localhost:3000/dashboard
# Origin header sent: Origin: http://localhost:3000
# These are all DIFFERENT origins:
# http://localhost:3000
# http://localhost:8080 (different port)
# https://localhost:3000 (different scheme)
# http://127.0.0.1:3000 (different host)
# http://app.example.com
# http://api.example.com (different subdomain)
# These do NOT match an allowed origin of "https://myapp.com":
# https://myapp.com/ (trailing slash)
# https://www.myapp.com
# http://myapp.comSimple Requests and Preflights
The browser splits cross-origin requests into two groups. A simple request (GET, HEAD, or POST with a form-style content type and no custom headers) is sent straight away with an Origin header, and the browser then checks the Access-Control-Allow-Origin header on the response. Anything else, such as a JSON POST, a PUT or DELETE, or any request carrying an Authorization header, is preflighted: the browser first sends an OPTIONS request asking permission, and only sends the real request if the answer is yes.
Most real APIs send JSON and a bearer token, so almost every call from a SPA is preflighted. This matters because the preflight is a separate request that carries no credentials. If your security layer demands authentication on it, the preflight fails with 401 or 403 and the real request is never sent. The browser console reports this as a CORS error even though the root cause is authentication.
Shell
# ── Simple request ────────────────────────────────────────────────────
# GET /api/users HTTP/1.1
# Origin: http://localhost:3000
#
# Allowed:
# HTTP/1.1 200 OK
# Access-Control-Allow-Origin: http://localhost:3000
# Vary: Origin
#
# Not allowed (Spring answers 403 with body "Invalid CORS request"):
# HTTP/1.1 403 Forbidden
# ── Preflight, sent automatically before the real request ─────────────
# OPTIONS /api/users HTTP/1.1
# Origin: http://localhost:3000
# Access-Control-Request-Method: POST
# Access-Control-Request-Headers: content-type, authorization
#
# Accepted:
# HTTP/1.1 200 OK
# Access-Control-Allow-Origin: http://localhost:3000
# Access-Control-Allow-Methods: GET,POST,PUT,DELETE
# Access-Control-Allow-Headers: content-type, authorization
# Access-Control-Max-Age: 3600
# Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
# ── Response headers worth knowing ────────────────────────────────────
# Access-Control-Allow-Origin the one origin (or *) that may read the response
# Access-Control-Allow-Methods methods allowed for the real request
# Access-Control-Allow-Headers request headers allowed on the real request
# Access-Control-Allow-Credentials true if cookies / HTTP auth may be sent
# Access-Control-Max-Age how long the browser may cache the preflight
# Access-Control-Expose-Headers extra response headers JavaScript may readThree Ways to Configure CORS in Spring
Spring gives you three entry points, and they are not interchangeable. @CrossOrigin is attached to a controller or handler method and is checked by Spring MVC. A global WebMvcConfigurer (addCorsMappings) is also checked by Spring MVC, but for every path you map. A CorsConfigurationSource bean registered through Spring Security's cors() DSL is applied by a servlet filter that runs before MVC, which is the only variant that can answer preflights before authentication rejects them.
If your application has no Spring Security, WebMvcConfigurer is the right default: one place, one policy, easy to read. If it does have Spring Security, define the policy once as a CorsConfigurationSource bean and let Security apply it; you do not need the WebMvcConfigurer version as well. Use @CrossOrigin only for the occasional endpoint that genuinely needs different rules from the rest.
Avoid mixing all three. When several are present the configurations are combined rather than replaced, which makes the final allowed-origin list hard to reason about and is a common source of "why is this origin allowed?" confusion.
Java
// Which one should I use?
//
// No Spring Security, one policy for the whole API
// -> WebMvcConfigurer.addCorsMappings()
//
// Spring Security on the classpath
// -> CorsConfigurationSource bean + http.cors(Customizer.withDefaults())
//
// One endpoint with unusual rules (public widget endpoint, webhook tester)
// -> @CrossOrigin on that method
//
// How a request flows when Spring Security is present:
// Request
// -> CorsFilter (from http.cors(...)) answers preflights, adds headers
// -> authentication / authorization filters
// -> DispatcherServlet
// -> @CrossOrigin / addCorsMappings checks (MVC-level CORS)
// -> controller@CrossOrigin on Controllers and Methods
@CrossOrigin is the quickest way to open up a single controller, which makes it handy for prototypes and for one-off exceptions. The annotation works at class level and method level, and method-level values are merged with the class-level ones, not substituted for them. If the class allows origin A and the method allows origin B, the method accepts both.
Be aware of the defaults. With no attributes, @CrossOrigin allows all origins, all request headers, the HTTP methods the handler is mapped to, and caches the preflight for 30 minutes. Credentials are not allowed unless you ask for them. In a real application the annotation is usually repeated across many controllers with the same origins, and that duplication is the signal to move to a global configuration.
Java
@RestController
@RequestMapping("/api/users")
@CrossOrigin(
origins = {"http://localhost:3000", "https://myapp.com"},
methods = {RequestMethod.GET, RequestMethod.POST,
RequestMethod.PUT, RequestMethod.DELETE},
allowedHeaders = {"Content-Type", "Authorization"},
exposedHeaders = {"Location"},
allowCredentials = "true",
maxAge = 3600
)
public class UserController {
@GetMapping
public List<UserResponse> findAll() { ... }
@PostMapping
public ResponseEntity<UserResponse> create(@RequestBody @Valid CreateUserRequest request) { ... }
}
// Method-level values are MERGED with class-level values.
@RestController
@RequestMapping("/api/products")
@CrossOrigin(origins = "https://myapp.com")
public class ProductController {
@GetMapping
public List<ProductResponse> findAll() { ... } // allows: myapp.com
@PostMapping
@CrossOrigin(origins = "http://localhost:3000")
public ResponseEntity<ProductResponse> create(...) { ... } // allows: myapp.com AND localhost:3000
}
// Defaults when you write a bare @CrossOrigin:
// origins all ("*")
// methods the methods the handler is mapped to
// allowedHeaders all
// exposedHeaders none
// allowCredentials not enabled
// maxAge 1800 seconds
// This combination is rejected at runtime, not just by the browser:
@CrossOrigin(origins = "*", allowCredentials = "true")
// IllegalArgumentException: When allowCredentials is true, allowedOrigins
// cannot contain the special value "*". List exact origins, or use
// originPatterns on a CorsConfiguration.Global CORS with WebMvcConfigurer
For an API without Spring Security, a WebMvcConfigurer is the cleanest approach. You describe the policy once, scoped by path pattern, and every controller under that pattern inherits it. Scope the mapping to your API prefix rather than "/**" so that static resources, actuator endpoints and error pages are not accidentally covered.
Two details trip people up. Mappings are evaluated in registration order and the first matching pattern wins, so the narrower pattern must be registered before the broader one. And allowedHeaders should list the headers your client really sends. Using "*" is fine for allowedHeaders when credentials are off, but it is worth spelling them out in production so the policy documents itself.
Java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
// Narrow rule first: first matching pattern wins.
registry.addMapping("/api/public/**")
.allowedOrigins("*")
.allowedMethods("GET", "HEAD")
.allowCredentials(false)
.maxAge(86400);
// Broad rule for the rest of the API.
registry.addMapping("/api/**")
.allowedOrigins(
"http://localhost:3000",
"https://myapp.com",
"https://admin.myapp.com")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("Content-Type", "Authorization", "Accept")
.exposedHeaders("Location", "X-Total-Count")
.allowCredentials(true)
.maxAge(3600);
}
}CORS with Spring Security
As soon as Spring Security is on the classpath, its filter chain runs before Spring MVC. Any CORS configuration that lives only in MVC (@CrossOrigin or addCorsMappings) is never reached for a preflight, because the unauthenticated OPTIONS request is rejected first. The fix is to hand Spring Security a CorsConfigurationSource and enable cors() on the chain. Security then inserts a CorsFilter ahead of the authentication filters, answers valid preflights itself, and adds the CORS headers to real responses.
With cors() enabled you normally do not need a blanket permitAll() rule for OPTIONS requests, since the CorsFilter handles preflights before authorization is evaluated. Adding one is harmless but can hide a misconfiguration, so prefer to leave it out and verify the preflight with curl. If you call http.cors(Customizer.withDefaults()), Spring Security looks for a bean named corsConfigurationSource, so keep that exact method name.
The csrf().disable() line below is appropriate for a stateless API that authenticates with a bearer token in the Authorization header. If you authenticate with a session cookie, do not disable CSRF just to make requests succeed; configure the CSRF token repository instead and add the token header to allowedHeaders.
Java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// Picks up the bean named "corsConfigurationSource" below.
.cors(Customizer.withDefaults())
// Stateless bearer-token API: no session cookie to forge, so CSRF is not needed.
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration publicApi = new CorsConfiguration();
publicApi.setAllowedOrigins(List.of("*"));
publicApi.setAllowedMethods(List.of("GET", "HEAD"));
publicApi.setMaxAge(86400L);
CorsConfiguration api = new CorsConfiguration();
api.setAllowedOrigins(List.of(
"http://localhost:3000",
"https://myapp.com"));
api.setAllowedMethods(List.of(
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
api.setAllowedHeaders(List.of(
"Authorization", "Content-Type", "Accept"));
api.setExposedHeaders(List.of("Location", "X-Total-Count"));
api.setAllowCredentials(true);
api.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// Register the more specific pattern FIRST; the first match wins.
source.registerCorsConfiguration("/api/public/**", publicApi);
source.registerCorsConfiguration("/api/**", api);
return source;
}
}Credentials, Cookies and Wildcard Origins
Whenever the browser is allowed to send cookies or HTTP authentication cross-origin, the server must respond with a specific origin, never "*". That rule is part of the CORS specification, and Spring enforces it by throwing an IllegalArgumentException when you combine allowCredentials(true) with a wildcard in allowedOrigins. The client must opt in as well: fetch needs credentials: "include" and axios needs withCredentials: true. If either side is missing, cookies are silently dropped and you end up debugging a login that "works but isn't remembered".
When you need to allow many origins that share a pattern, such as every preview deployment under *.myapp.com, use allowedOriginPatterns instead of allowedOrigins. Patterns are matched against the incoming Origin and the matched origin is echoed back, so they are compatible with credentials. Keep patterns as narrow as you can. A pattern like "https://*" is a wildcard in disguise and gives up the protection you were trying to add.
Also remember that cookies sent cross-site are subject to the SameSite attribute. A session cookie marked SameSite=Lax will not be sent on a cross-site fetch regardless of how generous your CORS policy is. For a frontend and API on different registrable domains you need SameSite=None; Secure, and for the same registrable domain (app.example.com and api.example.com) the requests are same-site and Lax is fine.
Java
// Many origins sharing a pattern, credentials allowed.
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of(
"https://*.myapp.com", // any subdomain, one level
"http://localhost:[*]" // any local dev port
));
config.setAllowCredentials(true); // OK with patterns, NOT OK with setAllowedOrigins("*")
// ── Client side ───────────────────────────────────────────────────────
// fetch:
// fetch("https://api.myapp.com/me", { credentials: "include" })
// axios:
// axios.get("https://api.myapp.com/me", { withCredentials: true })
//
// Without these the browser never attaches the cookie, whatever the server says.
// ── Cookie attributes for cross-site requests ─────────────────────────
// Different registrable domains (myapp.com -> api.other.com):
// Set-Cookie: SESSION=...; Secure; HttpOnly; SameSite=None
// Same registrable domain (app.myapp.com -> api.myapp.com):
// Set-Cookie: SESSION=...; Secure; HttpOnly; SameSite=LaxPer-Environment Origins with Configuration Properties
Hard-coding origins in Java means a redeploy whenever a domain changes, and it is easy to ship localhost origins to production by accident. Move the list into configuration, give each profile its own values, and keep the shared settings (methods, headers, max age) in the base file. A production profile that contains no localhost entries is also a simple thing to review in a pull request.
Note that a plain @Value injection cannot bind a YAML list to a List<String> field unless the value is written as a comma-separated string. @ConfigurationProperties handles real YAML lists, relaxed binding and validation, so it is the better fit here.
yaml
# application.yml: shared by every profile
app:
cors:
allowed-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
allowed-headers: Authorization,Content-Type,Accept
exposed-headers: Location,X-Total-Count
allow-credentials: true
max-age: 3600
# application-dev.yml
app:
cors:
allowed-origins:
- http://localhost:3000 # CRA / Next.js
- http://localhost:4200 # Angular
- http://localhost:5173 # Vite
# application-prod.yml
app:
cors:
allowed-origins:
- https://myapp.com
- https://admin.myapp.com
allowed-origin-patterns:
- https://*.preview.myapp.comBinding the Properties to a CorsConfigurationSource
A record with @ConfigurationProperties keeps the settings immutable, and @Validated lets the application refuse to start with an empty origin list instead of failing quietly at runtime. Build the CorsConfigurationSource from the record and expose it as the bean Spring Security expects.
Java
@Validated
@ConfigurationProperties(prefix = "app.cors")
public record CorsProperties(
@NotEmpty List<String> allowedOrigins,
List<String> allowedOriginPatterns,
@NotEmpty List<String> allowedMethods,
@NotEmpty List<String> allowedHeaders,
List<String> exposedHeaders,
boolean allowCredentials,
long maxAge
) { }
@Configuration
@EnableConfigurationProperties(CorsProperties.class)
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource(CorsProperties props) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(props.allowedOrigins());
if (props.allowedOriginPatterns() != null) {
config.setAllowedOriginPatterns(props.allowedOriginPatterns());
}
config.setAllowedMethods(props.allowedMethods());
config.setAllowedHeaders(props.allowedHeaders());
if (props.exposedHeaders() != null) {
config.setExposedHeaders(props.exposedHeaders());
}
config.setAllowCredentials(props.allowCredentials());
config.setMaxAge(props.maxAge());
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}Testing Preflights with curl
Test CORS from the command line before touching the frontend. A curl request with the right headers reproduces exactly what the browser sends, and it lets you read the raw response headers without the browser's filtering. Send the preflight with -X OPTIONS and the Access-Control-Request-* headers, then check that the status is 200 or 204 and that Access-Control-Allow-Origin echoes your origin.
An origin that is not allowed produces a 403 response with the body "Invalid CORS request" and no CORS headers. That is expected and is what you want to see for an unknown origin.
Shell
# Preflight for a JSON POST with a bearer token
curl -i -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,authorization"
# Expected (allowed origin):
# HTTP/1.1 200
# Access-Control-Allow-Origin: http://localhost:3000
# Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
# Access-Control-Allow-Headers: content-type,authorization
# Access-Control-Allow-Credentials: true
# Access-Control-Max-Age: 3600
# A disallowed origin:
curl -i -X OPTIONS http://localhost:8080/api/users \
-H "Origin: http://evil.example" \
-H "Access-Control-Request-Method: POST"
# Expected:
# HTTP/1.1 403
# Invalid CORS request
# Actual request, to confirm the headers also appear on real responses:
curl -i http://localhost:8080/api/users \
-H "Origin: http://localhost:3000" \
-H "Authorization: Bearer <token>"Testing CORS with MockMvc
Once the policy is right, pin it down with a test so that a refactor of the security config cannot quietly break the frontend. Use a full-context @SpringBootTest with @AutoConfigureMockMvc rather than @WebMvcTest alone, because the CorsFilter and your SecurityFilterChain need to be part of the context for the test to mean anything. Cover at least one allowed origin, one rejected origin and a preflight.
Java
@SpringBootTest
@AutoConfigureMockMvc
class CorsIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void preflightFromAllowedOriginIsAccepted() throws Exception {
mockMvc.perform(options("/api/users")
.header("Origin", "https://myapp.com")
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Headers", "content-type,authorization"))
.andExpect(status().isOk())
.andExpect(header().string("Access-Control-Allow-Origin", "https://myapp.com"))
.andExpect(header().string("Access-Control-Allow-Credentials", "true"));
}
@Test
void preflightFromUnknownOriginIsRejected() throws Exception {
mockMvc.perform(options("/api/users")
.header("Origin", "https://evil.example")
.header("Access-Control-Request-Method", "POST"))
.andExpect(status().isForbidden());
}
@Test
void publicEndpointAllowsAnyOriginWithoutCredentials() throws Exception {
mockMvc.perform(get("/api/public/status")
.header("Origin", "https://anywhere.example"))
.andExpect(status().isOk())
.andExpect(header().string("Access-Control-Allow-Origin", "*"))
.andExpect(header().doesNotExist("Access-Control-Allow-Credentials"));
}
}Troubleshooting Common CORS Errors
Most CORS problems fall into a handful of patterns, and the browser's console message usually points at the right one if you read it literally. The harder cases are the ones where the message is generic ("blocked by CORS policy") but the real cause is elsewhere, typically a 401, 403 or 500 on the preflight. Open the Network tab, select the OPTIONS request and look at its status code before you change any CORS settings.
Be careful with reverse proxies and gateways. If Nginx, an API gateway or Spring Cloud Gateway also adds CORS headers, the browser receives duplicate Access-Control-Allow-Origin headers and rejects the response with a "multiple values" error. Pick one layer to own CORS and disable it everywhere else. Finally, browsers cap Access-Control-Max-Age (Chrome at 2 hours, Firefox at 24 hours), so a very large maxAge does not cache the preflight for as long as you asked.
Shell
# ── Console message -> likely cause -> fix ────────────────────────────
# "No 'Access-Control-Allow-Origin' header is present on the requested resource"
# Origin not in the allowed list (check scheme, port, trailing slash),
# OR the request failed before CORS ran (401/403/500 on the preflight).
# -> Check the status of the OPTIONS request in the Network tab.
# -> Make sure cors() is enabled in the Spring Security chain.
# "Response to preflight request doesn't pass access control check: It does not
# have HTTP ok status"
# Spring Security rejected the unauthenticated OPTIONS request.
# -> Register a CorsConfigurationSource and call http.cors(...).
# "The value of the 'Access-Control-Allow-Origin' header must not be the wildcard
# '*' when the request's credentials mode is 'include'"
# Wildcard origin combined with credentials.
# -> Use exact origins or setAllowedOriginPatterns(...).
# "Request header field authorization is not allowed by
# Access-Control-Allow-Headers in preflight response"
# The header the client sends is missing from allowedHeaders.
# -> Add it (headers are case-insensitive).
# "Method PUT is not allowed by Access-Control-Allow-Methods in preflight response"
# -> Add PUT / PATCH / DELETE to allowedMethods.
# "The 'Access-Control-Allow-Origin' header contains multiple values"
# Both the proxy/gateway and Spring are adding CORS headers.
# -> Let only one layer set them.
# JavaScript can't read a custom response header (e.g. Location, X-Total-Count)
# -> Add it to exposedHeaders. Only a small set of headers is readable by default.
# Cookie isn't sent / session is lost
# -> credentials: "include" on the client, allowCredentials(true) on the server,
# and check the cookie's SameSite and Secure attributes.
# 403 "Invalid CORS request" from Spring
# The Origin header was present but did not match any allowed origin.
# -> Compare the exact Origin string against your configuration.