CORS Configuration
Understanding the same-origin policy, what preflight actually checks, configuring CORS in Spring correctly, and why allowedOrigins star with credentials is refused.
On this page
CORS errors are among the most misdiagnosed problems in web development, because the browser reports a server misconfiguration as a client-side error and the message rarely says which header was missing.
Key Takeaways
- CORS relaxes the same-origin policy; it is not an authorisation mechanism.
- Non-simple requests trigger a preflight
OPTIONSthat must succeed on its own. allowedOrigins: "*"withallowCredentials: trueis forbidden by the specification.- The CORS filter must run before authentication, or preflights get rejected as unauthenticated.
- Any header a client needs to read must be in
exposedHeaders.
Simple versus preflighted
That left-hand branch is why CORS is not a security control. A simple cross-origin POST reaches your
server and executes; the browser merely refuses to let the attacker's script read the response. CSRF
tokens, not CORS, are what stop that request being effective.
Configuration
@Configuration
public class CorsConfig {
@Bean
CorsConfigurationSource corsConfigurationSource(CorsProperties props) {
var config = new CorsConfiguration();
// Specific origins, from configuration per environment. Never "*"
// alongside credentials — the specification forbids it and Spring
// will refuse to start.
config.setAllowedOrigins(props.allowedOrigins());
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of(
"Authorization", "Content-Type", "X-Correlation-Id", "Idempotency-Key"));
// Headers a browser script may READ. Without this, JavaScript sees only
// the six safelisted response headers regardless of what you sent.
config.setExposedHeaders(List.of(
"X-Correlation-Id", "X-RateLimit-Remaining", "Location", "Link"));
config.setAllowCredentials(true);
// Cache the preflight so a chatty SPA does not double its requests.
config.setMaxAge(Duration.ofMinutes(30));
var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}app:
cors:
allowed-origins:
- https://app.acme.com
- https://admin.acme.comapp:
cors:
allowed-origins:
- http://localhost:3000
- http://localhost:5173Keeping origins in configuration rather than code means the development list never ships to production — a mistake that turns into "any localhost page can call our API with the user's session".
Be careful with the literal string null as an origin as well. Sandboxed iframes, file:// pages and
certain redirect chains all send Origin: null, so allowlisting it to make one local test pass grants
access to every one of those contexts at once.
One response header here is easy to overlook: Vary: Origin. Because the allowed origin is echoed back
per request, any cache in front of the API — a CDN, a reverse proxy, the browser's own — has to key its
entries on the request origin, or the first caller's Access-Control-Allow-Origin gets replayed to a
caller from a different one. Spring's CorsFilter sets Vary correctly; CORS hand-rolled in an
interceptor or a gateway filter frequently does not, and the resulting bug is intermittent and
origin-dependent, which makes it thoroughly unpleasant to track down.
setMaxAge is a request rather than an instruction. Browsers cap it — Chromium at two hours, Firefox
at twenty-four — and they evict the preflight cache more eagerly than most, so thirty minutes is a
reasonable ask and not a number to build a latency budget on.
Wiring it into Spring Security
@Bean
SecurityFilterChain api(HttpSecurity http, CorsConfigurationSource cors) throws Exception {
http
// Registers CorsFilter early in the chain, before authentication.
// Without this, the preflight OPTIONS is rejected as unauthenticated
// and the browser reports a CORS error for a working endpoint.
.cors(c -> c.configurationSource(cors))
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
// Preflights carry no credentials by design.
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
return http.build();
}This ordering issue accounts for a large share of "CORS is broken" reports. The endpoint works, the configuration looks right, and the preflight is being rejected by the authentication filter before the CORS filter ever sees it.
The wildcard rule
// Throws at startup: "When allowCredentials is true, allowedOrigins cannot
// contain the special value '*'".
config.setAllowedOrigins(List.of("*"));
config.setAllowCredentials(true);The specification forbids this because the combination would mean any website could make authenticated requests to your API using the visitor's cookies and read the responses. It is a complete bypass of the same-origin policy for authenticated resources.
allowedOriginPatterns exists for genuinely dynamic cases — a subdomain per tenant:
// Matches https://acme.app.example.com but not an arbitrary origin.
config.setAllowedOriginPatterns(List.of("https://*.app.example.com"));
config.setAllowCredentials(true);Keep patterns tight. https://*.example.com also matches an origin an attacker controls if you ever
allow customer-supplied subdomains.
Per-controller CORS
@RestController
@RequestMapping("/api/v1/public")
// Global config is preferable; this is for a genuine per-endpoint exception,
// such as a public widget embedded on customer sites.
@CrossOrigin(origins = "*", allowCredentials = "false", maxAge = 3600)
public class PublicWidgetController { }Note allowCredentials = "false" — the wildcard is permitted only without credentials, and for a
genuinely public read-only endpoint that combination is correct.
Mixing global and annotation-based configuration makes the effective policy hard to reason about. Pick global as the default and use the annotation only where you can articulate why this endpoint differs.
Debugging
# Simulate the browser's preflight exactly.
curl -i -X OPTIONS https://api.acme.com/api/v1/orders \
-H 'Origin: https://app.acme.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: Content-Type, Authorization'A correct response contains:
HTTP/1.1 200
Access-Control-Allow-Origin: https://app.acme.com
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1800Three failure patterns cover almost every case. No Access-Control-Allow-Origin means the origin
is not in your list, usually a scheme or port mismatch — http://localhost:3000 and
https://localhost:3000 are different origins. A 401 on the OPTIONS means the filter ordering
above. And a missing header in Access-Control-Allow-Headers means a custom header the client
sends is not permitted; the browser message names it.
What to take away
Remember that CORS controls what a browser lets a script read, not what reaches your server. Keep
allowed origins in per-environment configuration, never combine a wildcard with credentials, and list
any header clients must read in exposedHeaders. When it breaks, curl the preflight — the answer is
almost always in that response.
Frequently Asked Questions
Why does allowedOrigins star fail when credentials are enabled?
Is CORS a security control?
Why do I get a CORS error only on POST?
Related tutorials
- CSRF ProtectionHow CSRF works, how Spring CsrfFilter stops it, SameSite cookies as a second layer, the double-submit pattern for SPAs, and exactly when disabling CSRF is correct.
- HTTP Security HeadersEvery security header worth setting: what each one prevents, the values to use, Spring configuration, and how to roll out a Content Security Policy without breaking the site.
- SSL/TLS & HTTPS in Spring BootConfiguring TLS properly: the handshake, keystores and PKCS12, HTTP to HTTPS redirect, mutual TLS for service-to-service, cipher policy, and where to terminate.
- SQL Injection PreventionHow SQL injection actually works, why parameterised queries stop it, the JPA and JdbcTemplate patterns that are safe, the ones that are not, and how to test for it.