Cross-Origin Resource Sharing — headers, mechanics, patterns & troubleshooting
Cross-Origin Resource Sharing (CORS) is a browser-enforced security mechanism that controls whether a web page at one origin (scheme + host + port) can request resources from a different origin.
Without CORS, the Same-Origin Policy (SOP) blocks cross-origin reads. CORS provides a controlled way for servers to opt in to cross-origin requests.
Key insight: The server always receives and processes the request. CORS only controls whether the browser lets JavaScript read the response.
These headers are sent by the browser during cross-origin requests.
| Header | Set by | When | Purpose |
|---|---|---|---|
Origin |
Browser | All cross-origin requests | Identifies the requesting origin |
Access-Control-Request-Method |
Browser | Preflight only | Which HTTP method the real request will use |
Access-Control-Request-Headers |
Browser | Preflight only | Which custom headers the real request will include |
The browser adds Origin to all cross-origin requests. You cannot override or remove it from JavaScript.
Origin: https://example.com
The server uses this value to decide whether to allow the request.
Sent during preflight requests only. Tells the server which HTTP method the upcoming real request will use.
Access-Control-Request-Method: PUT
Sent during preflight requests. Comma-separated list of custom headers the real request will include.
Access-Control-Request-Headers: Content-Type, Authorization, X-Custom-Header
These headers are sent by the server to grant cross-origin access.
| Header | Required? | Scope | Purpose |
|---|---|---|---|
Access-Control-Allow-Origin |
Always | Simple & Preflight | Which origins may access the resource |
Access-Control-Allow-Methods |
Preflight | Preflight response | Allowed HTTP methods |
Access-Control-Allow-Headers |
Preflight | Preflight response | Allowed custom request headers |
Access-Control-Allow-Credentials |
Optional | Both | Allow cookies/auth headers |
Access-Control-Max-Age |
Optional | Preflight response | How long to cache preflight (seconds) |
Access-Control-Expose-Headers |
Optional | Simple response | Which response headers JS can read |
The essential CORS header. Must be present in the response or the browser blocks the response from JavaScript.
Access-Control-Allow-Origin: *
Access-Control-Allow-Origin: https://example.com
⚠ Cannot use * when Access-Control-Allow-Credentials: true — you must specify an exact origin.
Tells the browser which HTTP methods are permitted for cross-origin requests. Only used in preflight responses.
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS
Which request headers the browser may use. Must mirror or include the headers from Access-Control-Request-Headers.
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With
When true, the browser includes cookies, TLS client certificates, and authorization headers in the cross-origin request.
Access-Control-Allow-Credentials: true
Restrictions when using credentials:
Access-Control-Allow-Origin must be an exact origin (not *)Access-Control-Allow-Headers cannot be *Access-Control-Expose-Headers cannot be *fetch() must set credentials: 'include' or credentials: 'same-origin'How long (in seconds) the browser should cache the preflight result. Reduces OPTIONS requests.
Access-Control-Max-Age: 86400
Browsers cap this: Chrome = 7200s (2h), Firefox = 86400s (24h).
By default, JavaScript can only read CORS-safelisted response headers. This header exposes additional ones.
Access-Control-Expose-Headers: X-Total-Count, X-Request-Id, Link
Safelisted by default: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Pragma. All others require explicit exposure.
Sent directly — no preflight check. Must satisfy all conditions:
GET, HEAD, or POSTAccept, Accept-Language, Content-Language, Content-Type (with restrictions)Content-Type only:application/x-www-form-urlencoded, multipart/form-data, or text/plainXMLHttpRequest.uploadReadableStream in request body// Simple request — sent directly
fetch('https://api.example.com/data', {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
Browser sends an OPTIONS request first. Triggered when any of these is true:
GET, HEAD, or POSTPOST with non-simple Content-Type (e.g., application/json)Authorization, X-Api-Key)ReadableStream// Preflighted — OPTIONS sent first
fetch('https://api.example.com/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token123'
},
body: JSON.stringify({ name: 'test' })
})
1. Browser sends OPTIONS request with:
Origin: https://frontend.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
2. Server responds with:
Access-Control-Allow-Origin: https://frontend.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 3600
3. If preflight passes → browser sends the real request
4. Server responds with data + Access-Control-Allow-Origin
5. Browser exposes response to JavaScript
| Method | Preflight? | Why |
|---|---|---|
GET | No* | Simple method (unless custom headers) |
POST (form) | No* | Simple Content-Type |
POST (JSON) | Yes | application/json is not simple |
PUT | Yes | Not a simple method |
PATCH | Yes | Not a simple method |
DELETE | Yes | Not a simple method |
* No preflight unless custom headers are added.
# Nginx
add_header Access-Control-Allow-Origin *;
# Express
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
next();
});
Best for: public APIs, open data, no cookies/auth.
# Nginx — single origin
add_header Access-Control-Allow-Origin "https://myapp.com";
# Express — dynamic origin
const ALLOWED = ['https://myapp.com', 'https://admin.myapp.com'];
app.use((req, res, next) => {
const origin = req.headers.origin;
if (ALLOWED.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
next();
});
Always set Vary: Origin when the value changes based on the Origin header — prevents caching bugs.
# Nginx
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Credentials true;
add_header Vary Origin;
# Express + CORS middleware
const cors = require('cors');
app.use(cors({
origin: (origin, callback) => {
if (ALLOWED.includes(origin)) callback(null, true);
else callback(new Error('Not allowed by CORS'));
},
credentials: true
}));
# Client-side
fetch('https://api.example.com/me', {
credentials: 'include' // sends cookies
})
# Express — full CORS + preflight
app.options('*', cors()); // enable preflight for all routes
# Or manual:
app.options('/api/*', (req, res) => {
const origin = req.headers.origin;
if (ALLOWED.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,X-Request-ID');
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Max-Age', '86400');
res.setHeader('Vary', 'Origin');
res.sendStatus(204);
} else {
res.sendStatus(403);
}
});
res.setHeader('Access-Control-Expose-Headers',
'X-Total-Count, X-Request-Id, X-RateLimit-Remaining');
// Now JS can read:
// response.headers.get('X-Total-Count') ✅
// response.headers.get('X-Request-Id') ✅
Cause: Server didn't send Access-Control-Allow-Origin at all, or the origin doesn't match.
Fix: Add the header to the response. Check that the value exactly matches the requesting Origin (including trailing slashes).
Cause: Using wildcard * with credentials: 'include' or withCredentials: true.
Fix: Return the specific origin instead of *.
Cause: Request uses a header not listed in Access-Control-Allow-Headers.
Fix: Add the header to Access-Control-Allow-Headers. Common missing ones: Authorization, Content-Type (for JSON).
Cause: Using PUT, PATCH, DELETE etc. and server doesn't list them.
Fix: Add the method to Access-Control-Allow-Methods.
Cause: Server redirects (301/302) the OPTIONS request. Browsers reject preflight redirects.
Fix: Ensure OPTIONS is handled before any redirects. Don't redirect HTTP→HTTPS for the OPTIONS request.
curl, Postman, or any non-browser HTTP clientOPTIONS — are CORS headers present?Vary: Origin — missing Vary can cause wrong cached responses.add_header only applies to 2xx by default in nginx. Use always: add_header ... always;Copy and run this in your terminal to test CORS headers on any endpoint:
curl -H "Origin: https://example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type,Authorization" \
-X OPTIONS \
-i \
https://your-api.com/endpoint
Look for Access-Control-* headers in the response.