The Most Confusing Error in Web Development
If you have spent any time building web applications that consume APIs, you have almost certainly encountered this dreaded console message: "Access to fetch at 'https://api.example.com' from origin 'https://mysite.com' has been blocked by CORS policy." It appears out of nowhere, your perfectly valid API call fails, and you are left wondering why the browser is sabotaging your own code.
CORS — Cross-Origin Resource Sharing — is one of the most misunderstood concepts in web development. Developers often patch it with quick fixes without understanding the underlying security model, which leads to either broken applications or dangerously permissive configurations. This guide will explain CORS from the ground up: what it is, why browsers enforce it, how preflight requests work, and how to configure it correctly across different server platforms.
The Same-Origin Policy: Where It All Begins
Before you can understand CORS, you need to understand the same-origin policy — the fundamental security mechanism that browsers have enforced since the late 1990s. The same-origin policy states that a script running on one origin can only access resources from the same origin. An "origin" is defined by three components:
| Component | Example | Different Origin? |
|---|---|---|
| Protocol (scheme) | http:// vs https:// | Yes |
| Host (domain) | example.com vs api.example.com | Yes |
| Port | :443 vs :8443 | Yes |
So https://example.com and https://api.example.com are different origins, even though they share the same root domain. Likewise, http://example.com and https://example.com differ because of the protocol. This strictness exists for a very good reason: without it, any malicious website could make requests to your bank, email provider, or any other service where you are logged in, using your cookies and session tokens.
What Is CORS, Exactly?
CORS is a controlled relaxation of the same-origin policy. It is a mechanism that allows servers to declare which external origins are permitted to access their resources. The server communicates this permission through specific HTTP response headers, and the browser enforces the rules.
Think of it this way: the same-origin policy is a locked door. CORS is the server handing out keys to specific visitors. Without CORS, the door stays locked for everyone except those already inside (same origin). With CORS, the server can say, "I trust requests from https://myapp.com, let them through."
The Key CORS Headers
CORS relies on a set of HTTP response headers that the server sends back to the browser. Understanding these headers is essential for proper configuration.
Access-Control-Allow-Origin
This is the most important CORS header. It tells the browser which origin(s) are allowed to access the resource.
Access-Control-Allow-Methods
Specifies which HTTP methods are permitted for cross-origin requests. This header is used in the preflight response.
Access-Control-Allow-Headers
Lists the custom headers that clients are allowed to send. If your frontend sends Authorization or Content-Type: application/json, you must whitelist them here.
Access-Control-Allow-Credentials
When set to true, it tells the browser that cookies and authentication headers should be included in cross-origin requests. This has a critical interaction with Access-Control-Allow-Origin — you cannot use the wildcard * when credentials are enabled.
Access-Control-Max-Age
Specifies how long (in seconds) the browser should cache the preflight response. This reduces the number of OPTIONS requests for repeated API calls.
Simple Requests vs. Preflighted Requests
Not all cross-origin requests trigger a preflight. The browser categorizes requests into two types, and this distinction is where much of the confusion around CORS originates.
Simple Requests
A request is considered "simple" when it meets all of these criteria:
- Uses GET, HEAD, or POST method only
- Only uses "safe" headers: Accept, Accept-Language, Content-Language, Content-Type
- Content-Type is limited to: application/x-www-form-urlencoded, multipart/form-data, or text/plain
- No event listeners on the XMLHttpRequest upload object
- No ReadableStream in the request body
For simple requests, the browser sends the request directly and checks the CORS headers in the response. If the headers do not permit the origin, the browser blocks the JavaScript code from reading the response.
Preflighted Requests
Any request that does not meet the "simple" criteria triggers a preflight — an extra OPTIONS request that the browser sends before the actual request. This is extremely common in modern applications because sending JSON (Content-Type: application/json) or including an Authorization header immediately disqualifies a request from being "simple."
Configuring CORS in Nginx
Nginx is the most common web server where CORS is configured. Here is a robust configuration that handles both simple and preflighted requests:
map? The map directive lets you whitelist multiple origins dynamically. Since Access-Control-Allow-Origin only accepts a single origin (or *), you cannot list multiple domains directly. The map checks the incoming Origin header against your regex patterns and echoes back the matching one.
Configuring CORS in Apache
Apache uses mod_headers to set CORS headers. Make sure the module is enabled first:
Then add the following to your virtual host or .htaccess:
Configuring CORS in Node.js (Express)
For Node.js applications using Express, the cors middleware package is the standard approach:
If you need dynamic origin validation (e.g., checking against a database of allowed origins), you can pass a function:
Configuring CORS in PHP
In plain PHP, you set CORS headers at the top of your script before any output:
For Laravel applications, use the built-in config/cors.php configuration file that ships with the framework.
Common CORS Mistakes (and How to Fix Them)
These are the mistakes I see developers make repeatedly. Each one leads to either a broken application or a security vulnerability.
Mistake 1: Wildcard with Credentials
Wrong
Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: true
The browser will reject this combination. You cannot use * when credentials are involved.
Correct
Access-Control-Allow-Origin: https://myapp.comAccess-Control-Allow-Credentials: true
You must specify the exact origin when using credentials.
Mistake 2: Forgetting the Vary Header
When you dynamically set Access-Control-Allow-Origin based on the incoming Origin header, you must also set Vary: Origin. Without it, CDNs and browser caches may cache a response with Origin A and serve it for Origin B, causing CORS failures.
Mistake 3: Missing OPTIONS Handler
Your framework's router may not have a route for OPTIONS requests. Frameworks like Express.js handle this automatically with the CORS middleware, but if you are routing manually, you need to explicitly handle OPTIONS and return a 204 with the appropriate headers.
Mistake 4: CORS Headers Only on Success Responses
If your server returns a 500 error without CORS headers, the browser will report a CORS error instead of showing the actual error message. Always add CORS headers to all responses, including errors. In Nginx, use the always keyword:
Mistake 5: Duplicated Headers
If both your reverse proxy (Nginx) and your application (Node.js/PHP) set CORS headers, the browser receives duplicate headers and may reject the response. Choose one layer to handle CORS and remove it from the other.
Debugging CORS Errors with Browser DevTools
When you hit a CORS error, the browser DevTools are your best friend. Here is a systematic debugging process:
Access-Control-Allow-Origin is present and matches your origin.Security Implications of CORS
CORS is a security mechanism, and getting it wrong can expose your application to serious vulnerabilities. Here are the key security considerations:
Origin header and echo it back as Access-Control-Allow-Origin without validation. This effectively disables the same-origin policy for your API, allowing any website to make authenticated requests to your backend.
| Configuration | Security Level | Use Case |
|---|---|---|
Access-Control-Allow-Origin: * | Moderate | Public APIs with no authentication |
| Specific origin whitelist | High | Most applications |
| Reflected origin (no validation) | Dangerous | Never use this |
| No CORS headers (default) | Strict | Same-origin only |
When Wildcard Is Safe
Using Access-Control-Allow-Origin: * is perfectly safe for truly public APIs that do not use authentication — things like public weather data, open datasets, or CDN-hosted resources. The wildcard becomes dangerous only when combined with authenticated endpoints or when your API uses cookies for authentication.
CORS Does Not Replace Authentication
CORS only controls whether the browser allows JavaScript to read the response. The server still receives and processes the request regardless of CORS headers. This means CORS is not a substitute for proper authentication, authorization, or rate limiting. A malicious actor can always bypass CORS by using a non-browser HTTP client.
CORS and Cookies: The Credential Trap
When your frontend and API are on different subdomains (e.g., app.example.com and api.example.com), cookie-based authentication introduces additional CORS complexity:
On the server side, three conditions must be met simultaneously: Access-Control-Allow-Origin must be set to the exact origin (not *), Access-Control-Allow-Credentials must be true, and cookies must be set with SameSite=None; Secure attributes. Missing any one of these will cause the request to fail silently.
Proxy-Based Alternatives
Sometimes the cleanest solution is to avoid CORS entirely by using a reverse proxy. If your frontend and API share the same origin through a proxy, no CORS headers are needed.
In this setup, both the frontend and API appear to be on https://myapp.com, so the browser treats them as same-origin. No CORS configuration is needed, no preflight requests are sent, and cookies work without any special attributes. This is often the simplest and most secure approach.
Quick Reference Cheat Sheet
| Scenario | Solution |
|---|---|
| Public API, no auth | Access-Control-Allow-Origin: * |
| Private API, cookies | Specific origin + Credentials: true |
| Multiple allowed origins | Nginx map or dynamic validation |
| OPTIONS returns 404 | Add explicit OPTIONS handler returning 204 |
| Error responses missing CORS | Use always in Nginx add_header |
| Duplicate CORS headers | Handle CORS in one layer only (proxy OR app) |
| Want to avoid CORS entirely | Use a reverse proxy for same-origin |
Wrapping Up
CORS is not an enemy — it is a security feature that protects your users. The confusion usually comes from not understanding who enforces it (the browser), what it protects against (cross-origin data theft), and how to properly configure it (server-side response headers). Once you understand the flow — Origin header sent, server checks and responds, browser enforces — the errors stop being mysterious and start being straightforward to fix.
The most important takeaways: always handle OPTIONS requests, never reflect the Origin header without validation, never use wildcard with credentials, add CORS headers to error responses too, and consider whether a reverse proxy might be a simpler solution than CORS configuration altogether. With these principles in mind, you will never be stuck on a CORS error for more than a few minutes again.