CORS (Cross-Origin Resource Sharing)

A browser security mechanism your server configures but cannot enforce — and the exact configurations that cause the most production incidents

← Back to Index

What is CORS — and What Problem Does It Actually Solve?

CORS (Cross-Origin Resource Sharing) is a browser-enforced security mechanism that controls whether JavaScript on one origin is allowed to read responses from a different origin. It exists as a controlled relaxation of the Same-Origin Policy — the rule that says JavaScript can only read responses from the exact same origin (protocol + host + port) that served the page.

The threat the Same-Origin Policy defends against is concrete:

// The attack CORS prevents at the browser level
// 1. You are logged into https://mybank.com — your session cookie is in the browser
// 2. You open a new tab and visit https://evil.example.com
// 3. evil.example.com's JavaScript runs this:

fetch('https://mybank.com/api/transfer', {
    method: 'POST',
    credentials: 'include',   // sends YOUR session cookie to the bank
    body: JSON.stringify({ to: 'attacker-account', amount: 10000 })
});

// Without Same-Origin Policy: the bank receives a valid authenticated request
// and processes the transfer — you never clicked anything.
// With Same-Origin Policy: the browser blocks evil.example.com from reading
// the bank's response. The request MAY still be sent (this is where CSRF
// protection comes in — covered in Sessions & Cookies), but reading
// account data cross-origin is prevented entirely.
The fact most developers miss until production

CORS is enforced by the browser, not the server. The server doesn't block anything — it tells the browser what it permits, and the browser decides whether to hand the response to JavaScript. When you test with curl or Postman, you never see a CORS error because neither tool implements the Same-Origin Policy. CORS errors are exclusively a browser phenomenon. The request very often does reach your server — the browser just refuses to give the response to the JavaScript that initiated it.

What "Origin" Means Precisely

// Origin = Protocol + Host + Port — all three must match

https://shop.example.com:443/products/123
└──┬──┘  └────────┬────────┘ └─┬─┘
  protocol       host          port

// Same origin as https://shop.example.com
https://shop.example.com/cart       // ✓ same (different path is irrelevant)
https://shop.example.com:443/other  // ✓ same (443 is the default for HTTPS)

// Different origin — any single element differs
http://shop.example.com             // ✗ different protocol
https://api.example.com             // ✗ different subdomain → different host
https://shop.example.com:8080       // ✗ different port
https://example.com                 // ✗ different host (no subdomain)
https://shop.othersite.com          // ✗ different domain entirely

The CORS Handshake: Simple Requests and Preflight

Simple Requests — No Preflight

A request is "simple" (no preflight) when all three conditions hold: the method is GET, HEAD, or POST; the Content-Type is one of application/x-www-form-urlencoded, multipart/form-data, or text/plain; and no custom headers are added. Simple requests existed before CORS and must keep working, so they go through directly — the browser just checks the response headers before handing the response to JavaScript.

// Browser sends request with Origin header
GET /api/products HTTP/1.1
Host: api.shop.example.com
Origin: https://shop.example.com    // added automatically by the browser
Accept: application/json

// Server responds — the CORS header is what the browser checks
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://shop.example.com
Content-Type: application/json

[{"id": 1, "name": "Widget"}]

// Browser checks: does Allow-Origin match the page's origin?
// Yes → JavaScript gets the response
// No  → Browser throws CORS error, JavaScript never sees the body

Preflight — The Hidden Round Trip

Any request that isn't "simple" triggers a preflight: the browser sends an OPTIONS request first to ask "will you accept this?" before sending the actual request. This adds a full network round trip before every non-trivial API call — understanding this is important for performance, not just for debugging.

// This fetch triggers a preflight — Authorization and Content-Type: application/json
// are both outside the "simple" definitions
fetch('https://api.shop.example.com/api/orders', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',    // triggers preflight
        'Authorization': 'Bearer eyJ...'        // custom header → preflight
    },
    body: JSON.stringify(orderData)
});

// ── Step 1: Browser sends OPTIONS ────────────────────────────────────
OPTIONS /api/orders HTTP/1.1
Host: api.shop.example.com
Origin: https://shop.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

// ── Step 2: Server approves the preflight ────────────────────────────
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 3600    // cache this answer for 1 hour

// ── Step 3: Only now does the actual POST go ─────────────────────────
POST /api/orders HTTP/1.1
Host: api.shop.example.com
Origin: https://shop.example.com
Content-Type: application/json
Authorization: Bearer eyJ...

{"productId": 42, "quantity": 1}
Access-Control-Max-Age is a free performance win

Setting Max-Age tells the browser to cache the preflight result — subsequent requests to the same origin use the cached answer instead of sending another OPTIONS round trip. The browser maximum is 7200 seconds (Chrome/Firefox); 3600 is a common production value. Without it, every authenticated API call sends two HTTP requests instead of one. On a mobile connection with 150ms RTT, that's 150ms of latency paid before every single request that carries a custom header.

CORS Headers Reference

HeaderDirectionPurpose
OriginRequestThe requesting origin — set by the browser, never by you
Access-Control-Allow-OriginResponseWhich origin(s) may read the response. Either a specific origin or *
Access-Control-Allow-MethodsPreflight responseWhich HTTP methods are allowed
Access-Control-Allow-HeadersPreflight responseWhich request headers are allowed
Access-Control-Allow-CredentialsResponseWhether cookies/auth may be sent. * for origin is forbidden when this is true
Access-Control-Expose-HeadersResponseWhich response headers JavaScript is allowed to read. Default: only the "safe" headers
Access-Control-Max-AgePreflight responseHow long to cache the preflight result in seconds
Access-Control-Request-MethodPreflight requestThe method the browser is asking permission for
Access-Control-Request-HeadersPreflight requestThe headers the browser is asking permission for
Access-Control-Expose-Headers catches people off guard

By default, JavaScript can only read a small set of "safe" response headers: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma. Everything else — including your pagination headers like X-Total-Count, Location on a 201, or any custom header — is invisible to JavaScript unless you explicitly expose it. This is a very common production bug: "The header is in the response but my code can't read it."

Configuring CORS in Spring Boot — Which Method, When

Spring offers four configuration surfaces for CORS. The choice matters — using the wrong one in combination with Spring Security is one of the most common sources of intermittent CORS failures in production.

Option 1: @CrossOrigin — Controller or Method Level

Use this for one-off exceptions to your global policy, or in simple projects without Spring Security. It's the most visible (the policy is right next to the code it affects) and the least maintainable at scale (scattered across dozens of controllers).

@RestController
@RequestMapping("/api/products")
@CrossOrigin(
    origins = { "https://shop.example.com", "https://admin.example.com" },
    allowedHeaders = { "Content-Type", "Authorization" },
    methods = { RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE },
    maxAge = 3600
)
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping
    public List<ProductResponse> getProducts() {
        return productService.findAll();
    }
}

Option 2: WebMvcConfigurer — Global MVC CORS Policy

The correct default for apps without Spring Security. One place, all endpoints, no duplication.

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Value("${cors.allowed-origins}")
    private List<String> allowedOrigins;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins(allowedOrigins.toArray(new String[0]))
            .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
            .allowedHeaders("Content-Type", "Authorization")
            .exposedHeaders("X-Total-Count", "Location")
            .allowCredentials(true)
            .maxAge(3600);

        // Public read-only endpoints — wider permission
        registry.addMapping("/api/public/**")
            .allowedOriginPatterns("*")
            .allowedMethods("GET")
            .maxAge(3600);
    }
}
# application-dev.properties
cors.allowed-origins=http://localhost:3000,http://localhost:5173

# application-prod.properties
cors.allowed-origins=https://shop.example.com,https://admin.example.com

Option 3: CorsConfigurationSource Bean — With Spring Security

This is the only correct approach when Spring Security is in the classpath. Spring Security's filter chain runs before DispatcherServlet, which means Spring MVC's WebMvcConfigurer CORS configuration never executes for requests that Spring Security intercepts first. If you use WebMvcConfigurer with Spring Security, OPTIONS preflight requests are blocked at the security filter before your CORS config ever runs — the browser sees a 401 or 403 on the preflight, the actual request never fires, and you spend an hour debugging a "CORS error" that is really an authentication misconfiguration.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final List<String> allowedOrigins;

    public SecurityConfig(@Value("${cors.allowed-origins}") List<String> allowedOrigins) {
        this.allowedOrigins = allowedOrigins;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // Point Spring Security at our CorsConfigurationSource bean below.
            // This handles OPTIONS preflights BEFORE authentication runs —
            // if you omit this line, preflights get a 401 and nothing works.
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .csrf(csrf -> csrf.disable())   // stateless JWT API — no session, no CSRF needed
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            );
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(allowedOrigins);
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("Content-Type", "Authorization"));
        config.setExposedHeaders(List.of("X-Total-Count", "Location"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}

Which Option to Use

SituationUse
No Spring Security, simple projectWebMvcConfigurer.addCorsMappings()
Spring Security in the classpathCorsConfigurationSource bean + http.cors(cors -> cors.configurationSource(...))
One endpoint needs a different policy from the rest@CrossOrigin on that method only, overriding the global policy
Testing / local onlyProfile-specific properties, not hardcoded * in code

Credentials and the Wildcard Constraint

When the frontend needs to send cookies or an Authorization header in a cross-origin request, both sides must opt in, and the combination of allowCredentials=true with allowedOrigins=["*"] is explicitly forbidden by the spec.

// Frontend — must set credentials: 'include' explicitly
fetch('https://api.shop.example.com/api/profile', {
    credentials: 'include'   // sends cookies cross-origin; without this, cookies are omitted
});

// Backend — the two rules that BOTH must hold simultaneously
// WRONG — browser rejects this combination
config.setAllowedOrigins(List.of("*"));
config.setAllowCredentials(true);   // ✗ spec-forbidden, browser ignores Allow-Credentials

// CORRECT — explicit origin required when credentials are in play
config.setAllowedOrigins(List.of("https://shop.example.com"));
config.setAllowCredentials(true);   // ✓
Never use allowedOrigins("*") as a quick fix for credentials errors

When a developer gets the credentials mode is 'include' browser error and adds allowedOrigins("*") trying to open everything up, the browser still rejects it — the spec explicitly forbids * with credentials. The fix is not a wider wildcard; it is specifying the exact origin. This is the most common "I tried everything and CORS is still broken" loop.

Wildcard with allowedOriginPatterns

If you genuinely need to allow an entire domain family (e.g., all preview deployments under *.preview.shop.example.com), allowedOriginPatterns supports wildcards and remains compatible with credentials because it resolves to a specific origin at request time.

// Allows any subdomain of preview.shop.example.com, with credentials
config.setAllowedOriginPatterns(List.of("https://*.preview.shop.example.com"));
config.setAllowCredentials(true);   // ✓ — the browser gets back the specific requesting origin

// The response header echoes the actual requesting origin, not the pattern:
// Access-Control-Allow-Origin: https://feat-123.preview.shop.example.com

The Four CORS Errors You Will Definitely See

1. "No Access-Control-Allow-Origin header present"

// Full error:
// Access to fetch at 'https://api.shop.example.com/api/orders'
// from origin 'https://shop.example.com' has been blocked by CORS policy:
// No 'Access-Control-Allow-Origin' header is present on the requested resource.

// Cause: server sends no CORS headers — either not configured or wrong config method
// Debug: curl -v -H "Origin: https://shop.example.com" https://api.shop.example.com/api/orders
// If no Access-Control-* headers appear in the response, the config isn't applying.
// If Spring Security is present: are you using WebMvcConfigurer instead of CorsConfigurationSource?

2. "Preflight response doesn't pass access control check"

// Cause: the OPTIONS preflight is getting a non-2xx response, typically 401 or 403
// from Spring Security intercepting it before CORS runs.
// Debug: curl -v -X OPTIONS -H "Origin: https://shop.example.com"
//             -H "Access-Control-Request-Method: POST"
//             -H "Access-Control-Request-Headers: Content-Type,Authorization"
//             https://api.shop.example.com/api/orders
// If you see 401: Spring Security is rejecting the preflight before CORS processes it.
// Fix: use CorsConfigurationSource with http.cors(...) so Spring Security handles
// CORS before authentication — preflights have no credentials to authenticate with.

3. "The value of Allow-Origin must not be '*' when credentials mode is 'include'"

// Cause: allowedOrigins("*") combined with allowCredentials(true)
// This is spec-forbidden — the browser enforces it regardless of server config.
config.setAllowedOrigins(List.of("https://shop.example.com"));  // specify exactly
config.setAllowCredentials(true);

4. "Request header field X-Custom-Header is not allowed"

// Cause: a header your frontend sends is not in allowedHeaders on the server
// Every non-simple header must be explicitly listed (or use allowedHeaders("*"))
config.setAllowedHeaders(List.of(
    "Content-Type",
    "Authorization",
    "X-Request-Id"      // add whatever you send from the frontend
));

// Note: allowedHeaders("*") is fine for development; be explicit in production
// to avoid accidentally allowing headers that could carry sensitive data.

CORS vs CSRF — They're Not the Same Problem

AspectCORSCSRF
Protects againstReading cross-origin responses (data theft)Forged cross-origin state-changing requests (unwanted actions)
Enforced byBrowser (checks response headers)Server (checks tokens or cookie attributes)
What it blocksJavaScript reading the responseThe request itself being processed as legitimate
MechanismAccess-Control-* response headersCSRF tokens, SameSite cookie attribute
CORS does not prevent cross-origin requests from being sent and processed

A malicious page can still successfully send a POST to your server and trigger side effects — CORS only prevents the attacker's JavaScript from reading the response. If the harm comes from the action itself (a transfer, a state change, a deletion), not from reading data, CORS gives you no protection against that. That's what CSRF tokens and SameSite cookies defend against. The two mechanisms are complementary, not alternatives. For a complete treatment see Sessions & Cookies.

Debugging CORS Efficiently

# Step 1: test a simple GET with an Origin header
curl -v -H "Origin: https://shop.example.com" \
     https://api.shop.example.com/api/products

# Look for in the response headers:
# Access-Control-Allow-Origin: https://shop.example.com   ← config is working
# (nothing)                                               ← config is NOT applying

# Step 2: test the preflight OPTIONS directly
curl -v -X OPTIONS \
     -H "Origin: https://shop.example.com" \
     -H "Access-Control-Request-Method: POST" \
     -H "Access-Control-Request-Headers: Content-Type, Authorization" \
     https://api.shop.example.com/api/orders

# Expected: 204 No Content with Access-Control-Allow-* headers
# 401/403: Spring Security is intercepting before CORS runs → use CorsConfigurationSource
# 404:     Your OPTIONS endpoint isn't mapped → add OPTIONS to allowedMethods
# 200 without headers: CORS config not applied → check which config method you're using
Browser DevTools: Network tab is your primary CORS debugging tool

Filter by "Preflight" or look for the OPTIONS request before your actual request. Click it, inspect the Response Headers tab — the Access-Control-* headers (or their absence) tell you exactly what the server sent and why the browser rejected it. The Console tab shows the exact CORS error message, which is more specific than it looks once you know what each phrase means.

Best Practices and Common Pitfalls

✅ Do

  • Use CorsConfigurationSource bean + http.cors(cors -> cors.configurationSource(...)) the moment Spring Security is in the classpath — nothing else works reliably
  • Set maxAge to at least 600 seconds (Chrome's minimum for the cache to take effect) — every API call without it costs an extra round trip
  • Expose headers your frontend reads via setExposedHeadersLocation, pagination headers, rate-limit headers are all invisible by default
  • Drive allowed origins from environment-specific properties — never hardcode production origins in code that also runs in development
  • Use allowedOriginPatterns for wildcard subdomain needs, not allowedOrigins("*"), so it remains compatible with credentials

❌ Don't

  • Don't use WebMvcConfigurer.addCorsMappings() with Spring Security — preflights get 401 before your config runs
  • Don't combine allowedOrigins("*") with allowCredentials(true) — it's spec-forbidden and the browser will refuse it regardless of what the server sends
  • Don't think a CORS error means the request didn't reach the server — it usually did; CORS only controls whether JavaScript can read the response
  • Don't use allowedOrigins("*") in production for any endpoint that handles user data — it allows any JavaScript on any page to read your responses
  • Don't confuse CORS (reading cross-origin responses) with CSRF (forged cross-origin requests) — they're different threats, different defenses, both necessary

Interview Questions

🎓 Junior level

Q: What is CORS and why does it exist?
CORS (Cross-Origin Resource Sharing) is a browser mechanism that controls whether JavaScript on one origin can read responses from a different origin. It exists as a controlled relaxation of the Same-Origin Policy, which prevents malicious pages from reading data from other sites using the visitor's credentials.

Q: Why does a CORS error not appear when testing with curl but does appear in the browser?
CORS is enforced by the browser, not the server. curl doesn't implement the Same-Origin Policy — it sends requests and reads responses like any other program. The browser adds the Origin header automatically and checks the response headers before giving the response to JavaScript. The request often reaches the server either way; it's the browser that decides whether JavaScript can see the response.

Q: What is a preflight request?
A preflight is an OPTIONS request the browser sends automatically before a non-simple cross-origin request (any request with a custom header like Authorization, or with Content-Type: application/json). It asks the server "will you accept this?" before sending the actual request. If the server doesn't respond with the right Access-Control-* headers, the actual request is never sent.

🔥 Senior level

Q: You configure CORS with WebMvcConfigurer.addCorsMappings(), but preflight requests keep returning 401. Spring Security is in the classpath. What's happening?
Spring Security's filter chain runs before the DispatcherServlet, so it intercepts every request — including OPTIONS preflights — before Spring MVC's CORS configuration ever executes. The preflight has no authentication credentials (it's an automated browser probe, not a user request), so it fails the security check and returns 401. The fix is to configure CORS at the Spring Security layer via a CorsConfigurationSource bean and http.cors(cors -> cors.configurationSource(...)). This makes Spring Security handle CORS before authentication, so preflights are permitted without credentials, and only the actual requests need to authenticate.

Q: A developer sets allowedOrigins("*") and allowCredentials(true) to fix a CORS error with cookies. The browser still rejects it. Why, and what is the correct fix?
The combination is explicitly forbidden by the CORS specification. When credentials are involved (cookies, auth headers), the browser requires Access-Control-Allow-Origin to contain the exact requesting origin, not a wildcard — otherwise any site on the internet could make authenticated requests using the visitor's cookies. The browser enforces this regardless of what the server sends. The correct fix is to list the allowed origins explicitly: allowedOrigins("https://shop.example.com"). If multiple origins need to be supported with credentials, use allowedOriginPatterns (which resolves to a specific origin at request time and is allowed with credentials) or maintain an explicit allowlist.

Q: Your API responds with a Location header on 201 Created, and your React frontend reads it to navigate to the new resource. It works in local development but breaks in production. What's the likely cause?
By default, CORS only exposes a small set of "safe" response headers to JavaScript: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, and Pragma. The Location header is not in this list. In local development, if both the frontend and API run on the same origin, CORS doesn't apply and all headers are visible. In production, with separated origins, JavaScript can't read Location unless the server explicitly includes it in Access-Control-Expose-Headers. Fix: config.setExposedHeaders(List.of("Location")) in your CORS configuration.