CORS — Cross-Origin Resource Sharing
CORS is the browser asking the server 'is this origin allowed to read the response?' — and the server answering with headers that say yes, no, or yes-with-conditions.
The Problem
Browsers enforce a same-origin policy that prevents one website's JavaScript from reading responses from a different origin. This is essential for security — without it, a malicious site could read private bank data. But legitimate applications often need cross-origin access. CORS is the mechanism for servers to explicitly opt in to cross-origin requests.
Mental Model
Like a bouncer at a club checking if a name is on the guest list before granting entry. The browser is the bouncer, the server maintains the guest list (CORS headers), and the JavaScript trying to read the response is the person at the door.
Architecture Diagram
How It Works
Open browser DevTools on any modern web app and a CORS error will eventually appear. It is one of the most common errors in web development, and one of the most misunderstood. Here is exactly what happens and why.
The Same-Origin Policy
Browsers enforce a same-origin policy — JavaScript running on one origin cannot read HTTP responses from a different origin. An "origin" is defined as the combination of scheme + host + port:
https://app.example.com:443 ← this is one origin
https://api.example.com:443 ← different host = different origin
http://app.example.com:443 ← different scheme = different origin
https://app.example.com:8080 ← different port = different origin
This policy exists to prevent malicious websites from reading private data on other sites. Without it, a page at evil.com could make a fetch request to mybank.com/api/balance and read the response (the browser would automatically include the bank's cookies).
The same-origin policy blocks reading the response, not sending the request. The browser will send the request — the server will process it — but the browser will refuse to let JavaScript access the response body.
How CORS Works
CORS (Cross-Origin Resource Sharing) is the mechanism for servers to relax the same-origin policy for specific origins. The server sends HTTP headers that tell the browser: "Yes, I allow this origin to read my responses."
There are two types of cross-origin requests: simple requests and preflighted requests.
Simple Requests (No Preflight)
A request is "simple" if it meets ALL of these conditions:
- Method is
GET,HEAD, orPOST - Only uses "safe" headers:
Accept,Accept-Language,Content-Language,Content-Type Content-Typeis one of:application/x-www-form-urlencoded,multipart/form-data,text/plain
For simple requests, the browser sends the request directly with an Origin header:
GET /api/public-data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
The server responds with CORS headers:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json
{"data": "here"}
The browser checks: does the Access-Control-Allow-Origin header match my origin? If yes, JavaScript gets the response. If no, the browser blocks it and logs the CORS error.
Preflighted Requests
Most real-world API calls are NOT simple requests because they use Content-Type: application/json, include Authorization headers, or use methods like PUT or DELETE. For these, the browser sends an automatic preflight request — an OPTIONS request — before the actual request:
OPTIONS /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
The server must respond with the allowed methods and headers:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Only if the preflight response allows the origin, method, and headers does the browser send the actual request.
The Credentials Problem
By default, cross-origin requests do not include cookies or HTTP authentication. To include credentials, both sides must opt in:
Client side:
// Fetch API
fetch('https://api.example.com/data', {
credentials: 'include' // Send cookies cross-origin
});
// XMLHttpRequest
xhr.withCredentials = true;
Server side:
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://app.example.com # MUST be specific, not *
This is where the most common CORS mistake lives: Access-Control-Allow-Origin: * cannot be used with credentials. The browser will reject this combination because allowing any origin to send credentialed requests would defeat the purpose of the same-origin policy.
Server Configuration Examples
Nginx
# Global CORS configuration for an API
location /api/ {
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://app.example.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
add_header 'Access-Control-Max-Age' 86400;
add_header 'Content-Length' 0;
return 204;
}
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
proxy_pass http://backend;
}
Express.js
const cors = require('cors');
// Allowlist-based configuration
const corsOptions = {
origin: ['https://app.example.com', 'https://staging.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400
};
app.use(cors(corsOptions));
AWS S3 CORS Configuration
[
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
Why curl Works but the Browser Does Not
This is the most confusing thing about CORS for beginners. The API works perfectly when tested with curl. The same endpoint called from JavaScript in the browser throws a CORS error. Why?
curl is not a browser. It does not enforce the same-origin policy. It does not send Origin headers (unless one is added manually). It does not make preflight requests. CORS is purely a browser-enforced security mechanism.
The server still processes the request from the browser — it sends back a response. But the browser refuses to let JavaScript read the response unless the correct CORS headers are present. The data was sent over the wire and back; the browser just will not hand it to the calling code.
This means CORS is not a security mechanism that protects the server. It protects the user by preventing malicious JavaScript from reading cross-origin responses in the user's browser, using the user's cookies.
Advanced CORS Patterns
Dynamic Origin Validation
Instead of hardcoding a single origin or using *, validate the Origin header against an allowlist and echo it back:
const allowedOrigins = new Set([
'https://app.example.com',
'https://admin.example.com',
'https://staging.example.com'
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (allowedOrigins.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin'); // Critical for caching!
}
next();
});
The Vary: Origin header is essential when using dynamic origins. Without it, a CDN or browser cache might serve a response with Origin A's CORS header to a request from Origin B, causing a CORS failure.
Exposing Custom Response Headers
By default, only "CORS-safelisted" response headers are accessible to JavaScript: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Pragma. To expose custom headers:
Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining
Preflight Caching
Preflight requests add latency. The Access-Control-Max-Age header tells the browser how long to cache the preflight response:
Access-Control-Max-Age: 86400 # Cache for 24 hours
Be careful: if the CORS policy changes, cached preflight responses will not reflect the change until they expire. During policy changes, set a low Max-Age temporarily.
Common Debugging Workflow
When a CORS error appears in the browser console, follow this checklist:
# Step 1: Reproduce with curl to confirm the API works
curl -v -X OPTIONS https://api.example.com/data \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type"
# Check: Does the response include Access-Control-Allow-Origin?
# Step 2: Check the actual error message in the browser console
# "No 'Access-Control-Allow-Origin' header" → Server is not sending CORS headers
# "not equal to the supplied origin" → Origin mismatch
# "Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'"
# → Using wildcard with credentials
# Step 3: Verify the preflight (OPTIONS) response in DevTools Network tab
# Status should be 200 or 204, not 404 or 405
# Response must include all required Access-Control-Allow-* headers
# Step 4: Check if a proxy, CDN, or WAF is stripping headers
# Compare curl from a local machine vs curl from the server itself
The most frustrating CORS issues are caused by layers between the browser and the application — reverse proxies, CDNs, WAFs, and API gateways that strip or override CORS headers. Always check each layer in the request path.
Key Points
- •CORS is enforced by the browser, not the server. The server only sends headers — the browser decides whether to allow the response.
- •curl and Postman ignore CORS entirely because they are not browsers. If an API works in curl but not in the browser, it is a CORS issue.
- •Preflight requests (OPTIONS) only happen for 'non-simple' requests — those with custom headers, non-standard methods, or JSON content type.
- •Access-Control-Allow-Origin: * cannot be used with credentials (cookies). The server must echo the specific origin.
- •Preflight responses can be cached with Access-Control-Max-Age to avoid an OPTIONS request before every actual request.
Key Components
| Component | Role |
|---|---|
| Same-Origin Policy | Browser security rule that blocks scripts from one origin accessing resources on a different origin |
| Preflight Request (OPTIONS) | Automated browser request that checks whether the server allows the actual cross-origin request before sending it |
| Access-Control-Allow-Origin | Response header specifying which origins are permitted to read the response |
| Access-Control-Allow-Methods | Response header listing which HTTP methods are allowed for cross-origin requests |
| Access-Control-Allow-Credentials | Response header that must be true for the browser to include cookies or auth headers in cross-origin requests |
When to Use
CORS is needed whenever a browser-based application makes requests to a different origin (different domain, port, or protocol). Configure it on the API server or reverse proxy. For server-to-server calls, CORS does not apply — it is purely a browser security mechanism.
Tool Comparison
| Tool | Type | Best For | Scale |
|---|---|---|---|
| Nginx | Open Source | Configuring CORS headers at the reverse proxy level for all backends uniformly | Small-Enterprise |
| AWS API Gateway | Managed | Built-in CORS configuration with per-route control and automatic OPTIONS handling | Small-Enterprise |
| Cloudflare Workers | Managed | Edge-level CORS header injection with programmable rules | Enterprise |
| Express cors middleware | Open Source | Flexible CORS configuration in Node.js applications with origin allowlists | Small-Enterprise |
Debug Checklist
- Open browser DevTools Network tab and look for the OPTIONS preflight request. Check its response status and headers.
- Verify Access-Control-Allow-Origin in the response matches the requesting origin exactly (or is *).
- Check that Access-Control-Allow-Methods includes the HTTP method the request uses.
- If sending custom headers, verify Access-Control-Allow-Headers includes each one.
- If sending cookies or Authorization header, confirm Access-Control-Allow-Credentials: true is set and the origin is not *.
Common Mistakes
- Setting Access-Control-Allow-Origin: * while also setting Access-Control-Allow-Credentials: true — browsers reject this combination.
- Forgetting to handle the OPTIONS preflight request on the server, returning 404 or 405, which blocks the actual request.
- Caching preflight responses too aggressively (very long Max-Age) making it impossible to update CORS policy quickly.
- Reflecting the request's Origin header back as Access-Control-Allow-Origin without validating it against an allowlist — this is effectively no CORS protection.
- Only configuring CORS on the application server but not on CDN or reverse proxy layers that might strip or override headers.
Real World Usage
- •Every SPA (React, Vue, Angular) that calls a backend API on a different domain or port must deal with CORS.
- •GitHub's API returns CORS headers allowing browser-based tools like GitHub Pages apps to call the API directly.
- •Google Fonts serves fonts with Access-Control-Allow-Origin: * so any website can load them via CSS.
- •Stripe's JavaScript SDK calls api.stripe.com from customer websites, relying on CORS to allow the cross-origin requests.
- •AWS S3 requires explicit CORS configuration for buckets that serve assets loaded by browsers from different origins.