CORS End-to-End

Simple vs preflight, the credential trap, and how to debug it cold in DevTools.

1The one mental model to anchor everything

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.

One-liner

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.

2Simple vs preflight: the split

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.

CORS Request Decision
JS makes fetch/XHR
Cross-origin?
NO
Same-origin, no CORS needed
YES
Simple request?
Simple — sends directly
  • Method: GET / POST / HEAD only
  • Headers: only CORS-safelisted ones
    (Accept, Accept-Language, Content-Language, Content-Type*)
  • Content-Type: only application/x-www-form-urlencoded, multipart/form-data, or text/plain
  • No ReadableStream body
Non-simple — preflight first
  • Method: PUT / DELETE / PATCH / etc.
  • Any custom header: Authorization, X-Custom-*
  • Content-Type: application/json ← most common trigger
  • Credentials mode: include
Trap — "simple" is misleading: A POST with Content-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.

The preflight exchange

-- 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.

3The CORS header reference

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

4Credentials: the wildcard trap

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
Trap — wildcard + credentials = silent failure: If you set 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.
One-liner

Credentials + wildcard = always broken. If you're using cookies cross-origin, you need an explicit origin allowlist, not *.

5Debugging CORS cold in DevTools

A systematic approach when you hit a CORS error:

  1. Read the exact browser error message. Chrome spells out exactly which header is wrong or missing.
  2. Open Network tab, filter by the blocked request URL. Check: did the request reach the server (status code visible)? Or was it blocked before sending?
  3. Look for the OPTIONS preflight. If present, inspect its response headers. If absent but expected, the server may not handle OPTIONS at all (common nginx/express oversight).
  4. Check the actual response headers on the blocked request: is Access-Control-Allow-Origin present? Does it match the page's origin exactly (scheme + host + port)?
  5. Check credentials scenario. Is credentials: 'include' set? Then origin must not be * and Access-Control-Allow-Credentials: true must be present.
  6. Check error responses. The server must include CORS headers on 4xx and 5xx responses too — many servers only add them on success paths, so an auth error returns a bare 401 and the browser shows a CORS error instead of the actual status.
Trap — CORS headers on errors only: If your middleware adds CORS headers before the auth check, errors correctly show a 401. If auth runs first and rejects early (without the CORS headers), the browser sees a response with no Access-Control-Allow-Origin and reports a CORS error — masking the real 401. Order matters.

Common misconfigurations at a glance

Missing on error responses

Symptom

DevTools shows CORS error; server actually returned 401/500. CORS headers only present on 200s.

OPTIONS not handled

Symptom

Preflight OPTIONS returns 404 or 405. Framework router doesn't have a route for it.

Origin mismatch

Symptom

Server returns https://example.com but page is on http:// in local dev.

Wildcard + credentials

Symptom

200 in Network tab but JS throws. Classic: deployed * config meets session-cookie fetch.

6What CORS does NOT protect

Because CORS is browser-enforced only, it provides zero server-side security:

7platform-specific angle

The platform runs a global, multi-subdomain architecture — www.example.com, partner APIs, mobile web, affiliate widgets. In this context:

8Vary: Origin + CDN interaction

This is the subtlest CORS failure mode in production — it works in staging (no CDN) and silently misfires after the CDN warms up.

The cache-key problem

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

The fix: Vary: Origin

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

The cold-start trap

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.

CloudFront gotcha — Vary is not enough: AWS CloudFront does not respect the 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.

When you don't need Vary: Origin

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.

Cache fragmentation trade-off: 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.
One-liner

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.

9WebSocket and CORS

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.

What actually happens during a WebSocket connection

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
The critical difference from fetch: With 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.

Cookies + WebSocket = automatic CSRF vector

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: HTTP polling + WebSocket

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
One-liner

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.

Full loop

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."

10Check yourself — scenario quiz

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?

Verbal drill — do this out loud before next session

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:

Show me a Node/Express CORS middleware Dynamic origin reflection pattern Vary: Origin + CDN interaction CORS vs CSRF — how are they different? Service Worker and CORS What about same-site vs same-origin? WebSocket and CORS