On this page · sections
CORS & Preflight
Cross-Origin Resource Sharing is the browser’s cooperative protocol for letting JavaScript read responses from other origins — when the remote server opts in via response headers.
| Concept | What it is | Primary use |
|---|---|---|
| Same-origin policy | Blocks JS from reading cross-origin responses | Default security boundary |
| CORS | Server opt-in via response headers | Controlled cross-origin API access |
| Simple request | GET/HEAD/POST with safelisted headers | Skips preflight |
| Preflight | OPTIONS probe before non-simple requests | Validates method + custom headers |
| Credentials | Cookies / Authorization on cross-origin calls | Requires specific Allow-Origin |
Same-Origin Policy
Definition
Origin = scheme + host + port. JavaScript on https://app.example.com cannot read responses from https://api.other.com unless the API sends matching CORS headers.
// Page origin: https://app.example.com
async function loadProfile() {
const res = await fetch('https://api.other.com/user', {
credentials: 'include',
});
// Without ACAO matching app.example.com:
// TypeError: Failed to fetch (network OK — JS blocked)
return res.json();
}
// Same-origin — no CORS gate
fetch('/api/user').then((r) => r.json());The HTTP request often completes. The browser withholds the response body from JavaScript unless CORS headers opt in. CORS is not a firewall — server-side code and curl are unaffected.
Real-world example
Without SOP + CORS, any site you visit could silently call your bank’s API with your cookies and read the JSON. CORS protects the user’s browser session, not the server — the API still needs auth.
Interview: CORS blocks JS reads, not requests. The network call may succeed; the browser hides the body from script.
Simple vs Non-Simple Requests
Definition
A simple request skips preflight:
- Method: GET, HEAD, or POST
- Headers: only CORS-safelisted ones (
Accept,Content-Typelimited toapplication/x-www-form-urlencoded,multipart/form-data,text/plain) - No
ReadableStreambody readers
Anything else triggers an OPTIONS preflight first.
const API = 'https://api.example.com';
// Simple — no preflight
fetch(`${API}/health`);
// Non-simple — OPTIONS preflight first
fetch(`${API}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Ada' }),
});
// Non-simple — Authorization triggers preflight
fetch(`${API}/me`, {
headers: { Authorization: 'Bearer token123' },
});Browser → OPTIONS (preflight)
Server → 204 + Allow-Origin / Allow-Methods / Allow-Headers
Browser → POST (actual request)
Server → 200 + JSON + CORS headersAccess-Control-Max-Age caches preflight results — reduces OPTIONS spam on repeat calls.
Interview:
Content-Type: application/jsonand custom headers likeAuthorizationalways preflight cross-origin.
Preflight Response Headers
Definition
On OPTIONS, the browser sends Origin, Access-Control-Request-Method, and optionally Access-Control-Request-Headers. The server must reply with:
Access-Control-Allow-Origin(specific origin or*)Access-Control-Allow-Methodsincluding the real methodAccess-Control-Allow-Headersechoing custom headers- For credentialed requests:
Access-Control-Allow-Credentials: trueand a specific origin — never*
Both preflight and the actual response need CORS headers.
function corsPreflight(req, res) {
const origin = req.headers.origin;
const allowed = ['https://app.example.com'];
if (!allowed.includes(origin)) {
res.writeHead(403);
return res.end();
}
res.writeHead(204, {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '3600',
Vary: 'Origin',
});
res.end();
}Missing any required piece → TypeError: Failed to fetch even if Network tab shows 200.
Real-world example
“OPTIONS works, POST fails” usually means missing Access-Control-Allow-Origin on the actual response, or a credentials mismatch (credentials: 'include' with Allow-Origin: *).
Use Vary: Origin when echoing dynamic origins — without it, CDNs may serve one origin’s CORS header to another.
Interview: Debug both OPTIONS and the real request. Error responses (502/504) still need CORS headers or JS can’t read the error.
Credentials and Wildcard Risks
Definition
With credentials: 'include' or cookies, the spec forbids Access-Control-Allow-Origin: *. You must return the specific requesting origin plus Allow-Credentials: true.
const ALLOWED = new Set([
'https://app.example.com',
'https://staging.example.com',
]);
function safeCors(req, res) {
const origin = req.headers.origin;
if (!origin || !ALLOWED.has(origin)) {
res.writeHead(403);
return res.end();
}
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
res.end(JSON.stringify({ ok: true }));
}
// Anonymous public API — wildcard OK, no credentials
function publicRead(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.end(JSON.stringify({ version: '1.0' }));
}Reflecting req.headers.origin without an allowlist is credential leakage to any site — not safer than *.
SameSite cookies and CORS are separate layers — both matter for auth.
Interview:
*+ credentials is forbidden. Blind origin reflection without allowlist is a security bug.
Gateway-Centralized CORS
Definition
At scale, centralize CORS at the edge — API gateway or CDN — with a config-driven allowlist. Internal service mesh uses mTLS; no CORS needed east-west.
const ORIGINS_BY_ENV = {
production: ['https://app.example.com'],
staging: ['https://staging.example.com', 'http://localhost:3000'],
};
function gatewayCors(env) {
const allowed = new Set(ORIGINS_BY_ENV[env] || []);
return function corsMiddleware(req, res, next) {
const origin = req.headers.origin;
if (req.method === 'OPTIONS') {
if (!origin || !allowed.has(origin)) {
res.writeHead(403);
return res.end();
}
res.writeHead(204, {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '7200',
Vary: 'Origin',
});
return res.end();
}
if (origin && allowed.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}
next();
};
}A BFF (Next.js route handler on same origin) eliminates browser CORS for first-party flows — browser calls /api/users, server proxies internally.
Interview: One audited policy surface at the gateway; BFF for same-origin; never “disable CORS” browser extensions in prod runbooks.
Senior Interview Summary
Mental model
SOP → Blocks JS cross-origin reads by default
CORS → Server opts in via response headers
Preflight → OPTIONS before non-simple cross-origin requests
Credentials → Specific origin required; never *Practical rules
- Same-origin SPA →
/apiPOST does not preflight - JSON POST cross-origin → always preflight (+1 RTT)
- Check CORS headers on both OPTIONS and actual response
- Use
Vary: Originwith dynamic allowlists - Centralize at gateway; version allowlist in infra-as-code
What to say in interviews
“The request often completes — the browser withholds the response from JavaScript unless CORS headers match. For credentialed APIs I return a specific origin from an allowlist, set
Vary: Origin, and put CORS on error responses too. For first-party flows I’d use a same-origin BFF to skip the problem entirely.”
Common traps
- “CORS blocks the HTTP request” — it hides the response from JS
- “CORS protects the server” — it protects the user’s session; server still needs auth
- Forgetting CORS on the actual POST after OPTIONS succeeds
- Reflecting any origin without validation