CORS Explained: Why Browsers Block API Requests and How to Fix It
CORS is the source of more developer frustration than almost any other browser security mechanism — not because it is poorly designed, but because its error messages are opaque, its rules are non-obvious, and it only manifests in a specific context that server-side developers often do not encounter during development. Understanding what CORS actually is and why it exists transforms it from an arbitrary obstacle into a predictable system with clear rules.
The Same-Origin Policy
CORS exists because of the same-origin policy. Browsers enforce a rule that JavaScript running on one origin (a combination of scheme, hostname, and port) cannot make HTTP requests to a different origin by default. A page at https://app.example.com cannot, without permission, make a fetch request to https://api.otherdomain.com.
The same-origin policy exists to protect users. Without it, any website you visit could use your browser — and your active session cookies — to make authenticated requests to your bank, your email provider, or any other service where you are logged in. The attacker’s JavaScript would run in your browser, with access to your cookies, and could exfiltrate data or perform actions on your behalf without your knowledge. The same-origin policy prevents this by default.
CORS (Cross-Origin Resource Sharing) is the mechanism by which servers can explicitly grant exceptions to the same-origin policy. Rather than eliminating the protection, CORS extends it in a controlled way: the server declares which origins are permitted to make cross-origin requests.
Simple Requests vs Preflight
Not all cross-origin requests are treated equally. Browsers distinguish between simple requests and requests that require a preflight.
Simple requests — GET or POST with certain content types and no custom headers — are sent directly to the server. The browser examines the response headers and either allows or blocks the JavaScript from reading the response. The request itself reaches the server either way; only the response visibility is controlled.
Requests with custom headers, non-standard content types, or methods other than GET and POST trigger a preflight. Before sending the actual request, the browser sends an HTTP OPTIONS request to the same endpoint, asking the server: “will you accept this kind of request from this origin?” The server responds with headers declaring what it allows. If the preflight is approved, the browser sends the actual request. If not, the actual request is never sent and the JavaScript receives an error.
This distinction matters for API developers. When a developer adds an Authorization header or uses Content-Type: application/json, they trigger a preflight. The OPTIONS request arrives first, and if the server does not handle it correctly, the actual request never arrives. A server that logs only POST requests will have no record of why those requests are failing, which is why CORS debugging is disorienting until the preflight pattern is understood.
The CORS Headers
Servers communicate CORS policy through response headers.
Access-Control-Allow-Origin is the core header. It specifies which origins are allowed:
Access-Control-Allow-Origin: https://app.example.com
Or, for truly public APIs where any origin should be permitted:
Access-Control-Allow-Origin: *
The wildcard * allows any origin but disables credential passing — browsers will not send cookies or HTTP authentication headers with cross-origin requests when the response uses the wildcard. To allow credentials, you must specify a concrete origin, and additionally include:
Access-Control-Allow-Credentials: true
For preflight responses, additional headers describe what is permitted:
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Request-ID
Access-Control-Max-Age: 86400
Allow-Methods lists the HTTP methods the server accepts from this origin. Allow-Headers lists which request headers are permitted beyond the default safe headers. Max-Age tells the browser how long it can cache the preflight result, avoiding a preflight round trip on every request for the specified duration (86400 seconds is 24 hours).
Handling OPTIONS in Your API
Every endpoint that will receive cross-origin requests with custom headers or non-simple methods needs to respond correctly to OPTIONS requests. The OPTIONS handler should:
- Return 200 or 204 (not 404 or 405)
- Include the appropriate
Access-Control-Allow-*headers - Return no body (or a minimal one)
Most web frameworks have CORS middleware that handles this automatically. If you are configuring CORS headers manually, ensure OPTIONS is handled on every route that receives cross-origin requests, not just the routes you expect to be called directly. The preflight goes to the same path as the actual request; a route that accepts POST but not OPTIONS will produce confusing failures.
Dynamic Origin Validation
For APIs that must allow multiple specific origins — a production app, a staging environment, and a development environment — the Access-Control-Allow-Origin header must be dynamic. You cannot list multiple origins in a single header value; you can only specify one origin (or the wildcard).
The standard approach: maintain an allowlist of permitted origins. When a request arrives, check the Origin header against the allowlist. If it matches, reflect that origin back in the Access-Control-Allow-Origin response header. If it does not match, either omit the header or return a policy that denies the cross-origin request.
Origin: https://app.example.com # request header
Access-Control-Allow-Origin: https://app.example.com # response header (reflected)
Vary: Origin # important: tells caches this response varies by origin
The Vary: Origin header is critical when reflecting dynamic origins. Without it, a CDN or proxy cache might serve a response with Access-Control-Allow-Origin: https://app.example.com to a request from https://evil.com, defeating the policy entirely.
CORS Is Not a Security Mechanism on the Server Side
An important clarification: CORS protects users from cross-origin JavaScript in their browser. It does not protect your API from direct HTTP requests. curl, Postman, server-side code, and any non-browser HTTP client are not subject to CORS at all — they can call your API from any origin regardless of your CORS policy.
If you want to restrict which clients can call your API, that is the job of authentication and authorization. CORS controls only what browser-based JavaScript can access. This is why an API endpoint that returns CORS errors in a browser can be called successfully from curl — two completely different clients with different security models.
CORS configured correctly is invisible to legitimate users. Configured incorrectly, it produces vague browser errors that block your own application from working. The debugging path is always the same: open browser developer tools, find the failing request, read what the preflight returned (or did not return), and adjust the server’s CORS headers to match what the browser expects.