Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

CORS Configuration

Understanding the same-origin policy, what preflight actually checks, configuring CORS in Spring correctly, and why allowedOrigins star with credentials is refused.

Intermediate5 min readUpdated
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 OPTIONS that must succeed on its own.
  • allowedOrigins: "*" with allowCredentials: true is 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

A simple request is sent regardless — the browser only blocks reading the response. A preflighted one is not sent at all if the preflight fails.

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

CorsConfig.java
@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;
    }
}
application.yml
app:
  cors:
    allowed-origins:
      - https://app.acme.com
      - https://admin.acme.com
application-local.yml
app:
  cors:
    allowed-origins:
      - http://localhost:3000
      - http://localhost:5173

Keeping 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

SecurityConfig.java
@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

WildcardCredentials.java
// 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:

Patterns.java
// 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

ControllerCors.java
@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

terminal
# 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: 1800

Three 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?
The specification forbids the combination — a wildcard origin with credentials would let any site make authenticated requests to your API using the visitor session. Spring throws at startup rather than letting you ship it. Use allowedOriginPatterns with specific origins instead.
Is CORS a security control?
It is a browser control that relaxes the same-origin policy, not a server-side authorisation mechanism. It stops a script on another site reading your responses; it does not stop anyone calling your API with curl. Never treat CORS configuration as access control.
Why do I get a CORS error only on POST?
A JSON POST is not a simple request, so the browser sends a preflight OPTIONS first. If OPTIONS is blocked by authentication or not handled at all, the preflight fails and the browser reports it as a CORS error even though your POST handler is fine.

Related tutorials