Topics
v6.0.0
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.

ConceptWhat it isPrimary use
Same-origin policyBlocks JS from reading cross-origin responsesDefault security boundary
CORSServer opt-in via response headersControlled cross-origin API access
Simple requestGET/HEAD/POST with safelisted headersSkips preflight
PreflightOPTIONS probe before non-simple requestsValidates method + custom headers
CredentialsCookies / Authorization on cross-origin callsRequires 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-Type limited to application/x-www-form-urlencoded, multipart/form-data, text/plain)
  • No ReadableStream body 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 headers

Access-Control-Max-Age caches preflight results — reduces OPTIONS spam on repeat calls.

Interview: Content-Type: application/json and custom headers like Authorization always 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-Methods including the real method
  • Access-Control-Allow-Headers echoing custom headers
  • For credentialed requests: Access-Control-Allow-Credentials: true and 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 → /api POST does not preflight
  • JSON POST cross-origin → always preflight (+1 RTT)
  • Check CORS headers on both OPTIONS and actual response
  • Use Vary: Origin with 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

Last updated on