TL;DR
- Browsers enforce the same-origin policy. Servers must explicitly opt-in via
Access-Control-*headers. - Simple requests (GET/HEAD/POST with “simple” headers & content types) skip preflight. Anything else triggers an OPTIONS preflight.
- With credentials (cookies/HTTP auth) you must: set
Access-Control-Allow-Credentials: true, use a specificAccess-Control-Allow-Origin(no*), and set cookies withSameSite=None; Secure. - Cache preflights with
Access-Control-Max-Ageand avoid unnecessary custom headers to reduce preflight chatter. - Add
Vary: Originon responses that change by request origin. - For third-party APIs you can’t change: call them from your server (proxy) instead of the browser.
Why does CORS exist?
CORS is a protocol that safely relaxes the browser’s same-origin policy (which blocks reading responses across origins) by letting the target server say “this origin may read me.” It protects users while enabling legitimate cross-site API calls.
The actors (mental map)
Your Frontend (https://app.example.com) ── fetch ──► Browser
│ adds Origin: https://app.example.com
▼
Target API (https://api.example.net) ── responds with Access-Control-* headers
- Browser: Adds the
Originrequest header, runs the CORS checks, may send an OPTIONS preflight first. - API/Origin server: Decides which origins/methods/headers are allowed and returns the CORS response headers.
- CDN/Proxy: Must pass through and correctly cache CORS headers. Add
Vary: Originwhen responses differ by origin.
Core concepts in 60 seconds
Simple vs. preflighted
A request is simple if all are true:
- Method is GET, HEAD, or POST.
- Headers are only “simple” ones (
Accept,Accept-Language,Content-Language,Content-Typewithtext/plain,application/x-www-form-urlencoded, ormultipart/form-data). - No
fetchmode: 'no-cors'hacks (that yields opaque responses you can’t read).
Otherwise the browser first sends OPTIONS with:
Access-Control-Request-MethodAccess-Control-Request-Headers
Your server must answer with matching allow headers.
Credentials
If you send cookies or HTTP auth (fetch(..., { credentials: 'include' })):
- Response must include
Access-Control-Allow-Credentials: true. - Cannot use
Access-Control-Allow-Origin: *; you must echo a specific origin. - Cookies set by the API must use
SameSite=None; Secure.
Caching preflights
Use Access-Control-Max-Age: 600 (or similar) on the preflight response to cache the allowlist and cut OPTIONS traffic.
What to return (cheat sheet)
Preflight (OPTIONS) response
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin
Actual response
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Total-Count, Link
Vary: Origin
Core strategies
1) If you control the API, set the headers correctly
Express (Node.js)
import express from "express";
const app = express();
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Max-Age", "600");
}
if (req.method === "OPTIONS") return res.sendStatus(204);
next();
});
app.get("/api/data", (req, res) => res.json({ ok: true }));
app.listen(3000);
FastAPI (Python)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
origins = ["https://app.example.com", "https://admin.example.com"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
max_age=600
)
@app.get("/api/data")
def data():
return {"ok": True}
Nginx (reverse proxy)
map $http_origin $cors_origin {
default "";
"~^https://(app|admin)\.example\.com$" $http_origin;
}
server {
# ...
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET,POST,PUT,PATCH,DELETE,OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type,Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Max-Age 600 always;
return 204;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Vary "Origin" always;
}
2) Reduce preflights when possible
- Avoid unnecessary custom headers (each one forces preflight).
- Batch calls; prefer GET where it fits; align
Content-Typewith allowed “simple” types if you truly don’t need JSON. - Use
Access-Control-Max-Ageto cache preflights.
3) Handle credentials correctly
- Frontend:
fetch(url, { credentials: 'include' }). - Backend:
Access-Control-Allow-Credentials: true+ explicitAccess-Control-Allow-Origin. - Cookies:
Set-Cookie: foo=bar; SameSite=None; Secure; Path=/.
4) Make CDNs/proxies CORS-aware
- Pass through and cache CORS headers.
- Add
Vary: Originto avoid serving one origin’s allowlist to another. - Don’t strip the
Originrequest header.
5) For third-party APIs you can’t configure
- Call them from your server (backend-for-frontend) and return results to the browser.
- Or use the provider’s CORS-enabled endpoint if they offer one.
6) Local dev sanity
- Same hostname/port avoids CORS entirely.
- Otherwise use a dev proxy (Vite/Next/webpack) to route
/api/*to your backend. - Don’t sprinkle
mode: 'no-cors'—that creates opaque responses you can’t read.
Client examples
Plain fetch (no credentials)
const res = await fetch("https://api.example.net/widgets");
const data = await res.json();
Fetch with credentials (cookies)
const res = await fetch("https://api.example.net/session", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
Common errors & fast fixes
| Symptom (Console) | Likely cause | Fix |
|---|---|---|
| “No Access-Control-Allow-Origin header” | Server didn’t set it | Add Access-Control-Allow-Origin (specific origin) |
| “The value of the Access-Control-Allow-Origin header in the response must not be the wildcard * when the request’s credentials mode is include” | Using * with cookies | Echo the exact origin + Allow-Credentials: true |
| “Preflight response is not successful” | OPTIONS not handled or missing allow headers | Return 204 with the right Allow-* + Max-Age |
| Works in Postman but not browser | Postman ignores CORS | Test in a real browser; fix server headers |
| Cookie not sent | Missing SameSite=None; Secure | Set cookie flags; use HTTPS; credentials: 'include' |
| Wrong data served across sites | Missing Vary: Origin | Add Vary: Origin wherever Allow-Origin varies |