Simple vs preflight, the credential trap, and how to debug it cold in DevTools.
CORS stands for Cross-Origin Resource Sharing. It is a browser-enforced policy, not a server firewall. The browser blocks the JS from reading a cross-origin response unless the server explicitly grants permission via CORS headers. The server still receives (and processes) most requests — the browser just withholds the response from your code.
Origin = protocol + host + port. All three must match for requests to be
"same-origin." https://example.com and https://api.example.com are
different origins (different host). http://example.com and https://example.com
differ on protocol. https://example.com:8080 differs on port.
CORS is a browser contract, not a server lock. curl ignores it — so CORS headers don't secure your API; they just let browsers read it.
The browser applies different rules depending on how "safe" the request looks.
A simple request goes straight through; a preflighted request
is preceded by an automatic OPTIONS handshake.
application/x-www-form-urlencoded, multipart/form-data, or text/plainReadableStream bodyAuthorization, X-Custom-*application/json ← most common triggerincludeContent-Type: application/json
is not simple — application/json is not on the safelisted MIME types. This is
the #1 source of surprise preflight OPTIONS requests. Your JSON API always preflights.
-- STEP 1: browser auto-sends OPTIONS --
OPTIONS /api/hotels HTTP/1.1
Origin: https://example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
-- STEP 2: server responds (must include these) --
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
-- STEP 3: if preflight passes, actual POST fires --
Access-Control-Max-Age caches the preflight result in the browser for that many seconds
(Chrome caps at 7200s = 2 hrs). Without it every request in a session fires a new OPTIONS round trip.
| Header | Direction | Purpose |
|---|---|---|
Origin |
Request → | Sent automatically by browser on cross-origin requests |
Access-Control-Request-Method |
Preflight → | Declares the method of the actual request |
Access-Control-Request-Headers |
Preflight → | Declares custom headers the actual request will send |
Access-Control-Allow-Origin |
← Response | Specifies allowed origin. * or explicit origin (required) |
Access-Control-Allow-Methods |
← Preflight response | Comma-separated allowed methods |
Access-Control-Allow-Headers |
← Preflight response | Must echo back the requested headers |
Access-Control-Allow-Credentials |
← Response | true to allow cookies/auth to be included and read |
Access-Control-Expose-Headers |
← Response | Non-safelisted headers JS can read (e.g. X-Request-Id) |
Access-Control-Max-Age |
← Preflight response | Seconds to cache this preflight result |
Credentialed requests (cookies, Authorization headers, TLS certs) need two things
to align — client opt-in AND explicit server permission. The * wildcard is banned when
credentials are involved.
-- Client opt-in (credentials mode: include) --
fetch('https://api.example.com/profile', {
credentials: 'include' // sends cookies cross-origin
});
-- Server must respond with BOTH of these (wildcard * is forbidden) --
Access-Control-Allow-Origin: https://example.com // explicit, not *
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: * and the client uses credentials: 'include',
the browser rejects the response even though it got a 200. The DevTools error reads:
"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'." This catches everyone eventually.
Credentials + wildcard = always broken. If you're using cookies cross-origin, you need an explicit origin allowlist, not *.
A systematic approach when you hit a CORS error:
OPTIONS at all (common nginx/express oversight).Access-Control-Allow-Origin present? Does it match the page's origin exactly (scheme + host + port)?credentials: 'include' set? Then origin must not be * and Access-Control-Allow-Credentials: true must be present.Access-Control-Allow-Origin
and reports a CORS error — masking the real 401. Order matters.
DevTools shows CORS error; server actually returned 401/500. CORS headers only present on 200s.
Preflight OPTIONS returns 404 or 405. Framework router doesn't have a route for it.
Server returns https://example.com but page is on http:// in local dev.
200 in Network tab but JS throws. Classic: deployed * config meets session-cookie fetch.
Because CORS is browser-enforced only, it provides zero server-side security:
The platform runs a global, multi-subdomain architecture — www.example.com, partner APIs,
mobile web, affiliate widgets. In this context:
Origin back only if it matches. More flexible than a hardcoded header.Access-Control-Max-Age: 86400 on stable APIs. On mobile connections (the core audience in Southeast Asia), each saved OPTIONS round-trip is ~50–150ms.Vary: Origin is a latent production bomb; see §8.This is the subtlest CORS failure mode in production — it works in staging (no CDN) and silently misfires after the CDN warms up.
A CDN's default cache key is the URL + Host. The Origin request header
is not included. When your server does dynamic origin reflection — checking the
request Origin against an allowlist and echoing it back — the response varies per caller
origin. But the CDN doesn't know that.
-- Request 1: from https://example.com --
GET /api/hotels Origin: https://example.com
→ CDN miss → origin server reflects back:
Access-Control-Allow-Origin: https://example.com
→ CDN stores this response under key: /api/hotels
-- Request 2: from https://partner.example.com --
GET /api/hotels Origin: https://partner.example.com
→ CDN HIT (same URL!) → returns CACHED response:
Access-Control-Allow-Origin: https://example.com ← wrong origin
→ Browser: "partner.example.com ≠ example.com" → CORS ERROR
Vary: Origin is an instruction to the CDN: "include the Origin request
header in the cache key." Now each distinct origin gets its own cache entry.
-- Correct response from origin server --
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Credentials: true
Vary: Origin
-- CDN now caches separately: --
/api/hotels + Origin: https://example.com → entry A
/api/hotels + Origin: https://partner.example.com → entry B
There is a subtler variant. A server-to-server call (a health check, a bot, an SSR Node server)
hits the CDN first — with no Origin header at all. The origin server
returns a response with no Access-Control-Allow-Origin (nothing to reflect).
The CDN caches it. The first browser request is a CDN hit — and gets a response with no ACAO → CORS error.
Vary: Origin fixes this too: requests with no Origin and requests with an
Origin are now different cache keys, so they never collide.
Vary response header by default. You must explicitly add Origin to the
distribution's cache policy → Headers to include in cache key. Setting
Vary: Origin on the response without this CloudFront config change does nothing.
Fastly and Varnish respect Vary natively; CloudFront requires explicit opt-in.
If you return Access-Control-Allow-Origin: *, the response is identical for
all origins — there's nothing to vary. No Vary: Origin needed. But remember: wildcard
is incompatible with credentials (§4), so for any credentialed endpoint you'll always need dynamic
reflection and therefore always need Vary: Origin.
Vary: Origin multiplies cache entries —
N allowed origins × M cached URLs. For the platform with dozens of partner origins this has a real cache
hit-rate cost. Mitigate by only adding CORS headers (and thus Vary: Origin) to endpoints
that are actually cross-origin-called — not blanket-adding them to every route.
Dynamic origin reflection without Vary: Origin works in staging and silently breaks in prod — the CDN caches origin A's headers and serves them to origin B.
WebSockets use a completely different security model from fetch/XHR.
The browser's CORS enforcement does not apply. This is the most dangerous
misconception about CORS: developers assume the browser protects them; for WebSockets, it doesn't.
A WebSocket starts as an HTTP upgrade request. The browser does send Origin
automatically — but it never checks Access-Control-Allow-Origin in the server's
response. There is no preflight. There is no CORS validation.
-- Browser sends the HTTP upgrade --
GET /ws HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Origin: https://example.com ← browser sends this…
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
-- Server responds with 101 (no ACAO header needed or checked) --
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
← no Access-Control-Allow-Origin required or enforced
fetch, if the server omits
CORS headers, the browser blocks JS from reading the response — the server is protected by default.
With WebSocket, if the server omits Origin validation, the browser completes the connection
and evil.com can send messages and read responses. There is no browser safety net.
The server must check Origin itself.
Unlike fetch, the WebSocket API has no credentials option.
The browser always sends same-site cookies on the upgrade request — you can't opt out.
This means: a malicious page can open a WebSocket to your server, the victim's session cookie
rides along, and if the server doesn't validate Origin, the attacker has an
authenticated socket.
-- Malicious page on evil.com --
const ws = new WebSocket('wss://api.example.com/realtime');
// Browser sends: Origin: https://evil.com
// Browser ALSO sends: Cookie: session=<victim's token> ← automatic
// If server doesn't reject: connection succeeds, attacker reads real-time data
-- Correct server-side defence (Node/ws library) --
const wss = new WebServer({ server });
wss.on('connection', (socket, req) => {
const origin = req.headers.origin;
if (!ALLOWED_ORIGINS.has(origin)) {
socket.close(1008, 'Origin not allowed'); // 1008 = policy violation
return;
}
});
Socket.IO starts with HTTP long-polling before upgrading to WebSocket. The polling requests
do go through CORS (they're regular HTTP). So Socket.IO has its own CORS config that
covers the polling phase — but the underlying WebSocket upgrade still relies on the same
server-side Origin check:
const io = new Server(server, {
cors: {
origin: 'https://example.com', // covers HTTP polling phase
credentials: true
}
});
// Socket.IO's built-in CORS handles the upgrade path too via its own handshake
WebSockets bypass CORS — the browser sends Origin but never validates the response. If your server doesn't reject unknown origins, any site can open an authenticated socket with the victim's cookies.
Concept: CORS is a browser mechanism that gates JS's ability to read cross-origin responses — it's not a server-side access control. Trade-off: wildcards simplify ops but break credential flows and signal a lax posture; explicit origin allowlists are safer but need env-awareness (dev vs staging vs prod origins all differ) and a Vary: Origin at the CDN. Anchor: "On our checkout API a 500 from the payment gateway masqueraded as a CORS error because the CORS middleware ran after the error handler — we moved CORS to the first middleware and added it to our error-response helpers." Impact: silent CORS misconfigs can block login for all users for hours, while preflight cache tuning (Max-Age: 86400) shaves ~50ms of OPTIONS latency per session on mobile. Invite: "I'd weigh the allowlist differently with dozens of micro-frontends on different subdomains — there I'd reach for dynamic origin reflection behind a server-side whitelist check."
0 / 6
Which fetch call triggers a CORS preflight?
All fetches go from https://example.com to https://api.example.com.
Your API sets Access-Control-Allow-Origin: *. A fetch with credentials: 'include' fails. Why?
A security audit points out your API has no CORS headers. A malicious site at evil.com runs fetch('https://api.example.com/bookings'). What actually happens?
DevTools shows a CORS error, but the Network tab shows the server returned 401. What's the most likely cause?
Your API uses dynamic origin reflection and is deployed behind a CDN. The first browser request from https://example.com works. The second from https://partner.example.com gets a CORS error, even though your allowlist includes both. What's missing?
A page on evil.com runs new WebSocket('wss://api.example.com/realtime'). The server has no Origin validation. The victim has an active session cookie. What happens?
Whiteboard prompt: "Walk me through what happens, step by step, when your React app on
example.com fires a fetch('https://api.example.com/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ...' }, credentials: 'include' })."
Aim to cover: two-phase (preflight → actual), exact headers in each direction, what the server must return, the credentials + wildcard constraint, and what breaks if CORS headers are missing from the 401 response.
Good follow-up topics: