CORS at the Protocol Level — Preflight, Credentials, Exposed Headers
TL;DR
CORS (Cross-Origin Resource Sharing) is the browser’s mechanism for letting servers opt into cross-origin requests beyond the simple ones HTML always allowed. The senior knowledge: same-origin policy is the default deny; CORS is the opt-in. Preflight (OPTIONS request) negotiates before non-simple requests. Credentials (cookies, Authorization) need both client and server opt-in. Access-Control-Expose-Headers controls which response headers JS can read. Most “CORS errors” trace to one of three patterns: forgotten OPTIONS handler, missing Access-Control-Allow-Credentials, or wrong origin.
Interview Q&A
Q: What is the same-origin policy?
A: A browser security rule: scripts on https://a.com cannot read responses from https://b.com by default. Specifically:
- Reading the response body, headers, or status from a cross-origin
fetch/XHRis blocked. - Reading from a cross-origin iframe’s
contentDocumentis blocked. - Cross-origin cookie reads are blocked.
The browser still sends the request (you can fire-and-forget cross-origin POSTs — the foundation of CSRF). It just won’t let your JS see the response.
“Origin” = scheme + host + port. http://a.com ≠ https://a.com ≠ https://a.com:8080.
Q: Why does CORS exist?
A: Same-origin policy is too strict for modern web apps — they routinely fetch from APIs on different origins. CORS lets the server explicitly opt in to cross-origin access.
GET /api/data HTTP/1.1
Origin: https://app.example.com
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json
The Access-Control-Allow-Origin header tells the browser “the server consented to this origin reading the response.” Without it, the browser hides the response from JS even though the network round-trip happened.
Q: Simple vs preflighted requests.
A: A request is “simple” if all of these hold:
- Method is
GET,HEAD, orPOST. - Headers are limited to a safelist (
Accept,Accept-Language,Content-Language,Content-Typewith restricted values). Content-Type(if present) isapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain.- No
ReadableStreamrequest body. - Event listeners on the upload object are not used.
Simple requests are sent directly. The browser checks the response’s Access-Control-Allow-Origin after the fact.
Non-simple requests (everything else — including application/json POSTs, Authorization headers, PUT/DELETE/PATCH) trigger a preflight — a separate OPTIONS request before the real one.
Q: Preflight — what happens?
A:
# Browser sends preflight automatically:
OPTIONS /api/data HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization
# Server responds:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 3600
If the preflight succeeds (server returned the right Allow-* headers), the browser then sends the real PUT. If the preflight fails, the real request is never sent; JS sees a CORS error.
Access-Control-Max-Age: 3600 lets the browser cache the preflight result for 1 hour — subsequent same-shape requests skip the preflight. Critical for perf with many small requests.
Q: Credentialed requests — cookies and Authorization.
A: By default, fetch does not send cookies or Authorization on cross-origin requests. You opt in:
fetch("https://api.example.com/data", { credentials: "include" });
And the server must opt in too:
Access-Control-Allow-Origin: https://app.example.com # MUST be a specific origin, NOT *
Access-Control-Allow-Credentials: true
Key rules:
Access-Control-Allow-Origin: *is forbidden with credentials. Must be the specific origin echoed back. Servers typically reflect theOriginheader from the request after validating against an allowlist.- Both sides opt-in — client
credentials: "include"+ serverAllow-Credentials: true. Either missing → cookies/auth not sent or response blocked. - Cookies are subject to
SameSiteindependently —SameSite=Laxcookies aren’t sent on most cross-origin requests regardless.
The most common credentialed-CORS bug: server returns Allow-Origin: * for credentials requests. Browser rejects. Fix: server must echo the specific origin.
Q: Exposing response headers.
A: Even with Allow-Origin set, JS can only read a default safelist of response headers (Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma).
For custom headers, server must opt in:
Access-Control-Expose-Headers: X-Total-Count, X-Pagination-Token
const res = await fetch(...);
res.headers.get("X-Total-Count"); // accessible because exposed
res.headers.get("X-Internal-ID"); // null — not exposed
Common case: pagination metadata in X-Total-Count. Without Access-Control-Expose-Headers, the header is there in the response but res.headers.get() returns null.
Q: Preflight tuning — Access-Control-Max-Age.
A: Preflights add latency (extra round trip). Cache them:
Access-Control-Max-Age: 86400 # 24 hours
Browser caches the preflight result per (origin, URL, method, headers) for that duration. Big perf win for chatty APIs.
Caveat: browsers cap Max-Age (Chrome: 2 hours; Firefox: 24 hours). Setting higher has no effect beyond the cap.
Q: Common CORS errors and their meanings.
A:
| Error | What |
|---|---|
| “No ‘Access-Control-Allow-Origin’ header” | Server didn’t return the header at all (or returned wrong origin). |
| “Response to preflight request doesn’t pass access control check: It does not have HTTP ok status” | OPTIONS handler returned 4xx/5xx — likely missing on the server. |
| “Credentials flag is true, but the ‘Access-Control-Allow-Origin’ is ‘*’” | Server returned * for a credentialed request. Echo specific origin. |
| “Request header field x-foo is not allowed by Access-Control-Allow-Headers” | Server didn’t list x-foo in Allow-Headers. |
| “Refused to set unsafe header ‘Origin’” | App tried to set Origin manually. Browser controls this. |
Read the message carefully — it usually tells you exactly what to fix.
Q: How does a CDN affect CORS?
A: Depends:
- Pass-through CDN — relays the origin’s headers;
Access-Control-Allow-Originfrom origin is what reaches the browser. - Edge caching — if the same URL has different origins (
Vary: Originnot set), the CDN may cache a response for origin A and serve it to origin B → wrong CORS header → broken.
Fix: either origin always returns the same Access-Control-Allow-Origin (* for public APIs), or Vary: Origin to cache per-origin.
For authenticated APIs with multiple SPA origins: the origin allowlist must be applied at the origin server, with Vary: Origin so the CDN caches per origin.
Q: Same-origin policy bypasses (legitimate).
A:
- CORS — server opts in.
- JSONP (legacy) — abuses
<script src="...">(script tags aren’t subject to SOP). Don’t use; security risk. postMessage— cross-origin window/iframe messaging (you call it explicitly withtargetOrigin).- Proxy — your server fetches and re-serves under your origin. Same-origin from the browser’s perspective.
For modern APIs, CORS is the answer. Proxy is the fallback when you can’t change the server (third-party API without CORS).
Q: COOP / COEP / CORP — modern isolation headers.
A: Newer security headers for stricter cross-origin isolation:
Cross-Origin-Opener-Policy: same-origin— page can’twindow.openand interact with cross-origin windows.Cross-Origin-Embedder-Policy: require-corp— subresources must opt in viaCross-Origin-Resource-Policy.Cross-Origin-Resource-Policy: same-origin— server declares “only same-origin pages may embed me as a subresource.”
Required to enable SharedArrayBuffer and high-resolution timers (post-Spectre). Most apps don’t need to think about these unless using shared memory APIs.
Gotchas / edge cases
- CORS errors are browser-side only — the request did hit the server. Server-side logging shows the request; browser hides the response from JS.
- Manually setting
Originheader in JS is blocked — browser-controlled. Don’t try. crossoriginattribute on<img>/<script>— enables CORS for the asset. Without it, the image loads butgetImageDatafrom<canvas>throws (security). With it, server must returnAccess-Control-Allow-Origin.Authorizationheader always triggers preflight — even with simple methods. Cache the preflight with Max-Age.Access-Control-Allow-Origincannot be a list — single origin, or*. To support multiple origins, server readsOriginrequest header, validates against allowlist, echoes it back.- Preflight with custom request body — preflight doesn’t include the body; OPTIONS is body-less. Server-side logging may confuse you.
fetch(..., { mode: "no-cors" })— opaque response; can’t read body or headers but the request fires. Useful for<img>-like behavior with no JS access; cache for SW.- Browser caches preflight per origin + URL + method + headers — varying any of these invalidates the cache.
What a senior is expected to say
- “Same-origin policy is default-deny for cross-origin reads; CORS is the server’s opt-in. The browser still sends the request — it just hides the response from JS unless headers permit.”
- “Simple requests (GET/POST safelist) go directly; non-simple (PUT, custom headers, JSON content-type) trigger a preflight OPTIONS first.”
- “Credentials require both opt-ins: client
credentials: 'include'+ serverAllow-Credentials: true. AndAllow-Origin: *is forbidden with credentials — must echo specific origin.” - “Custom response headers need
Access-Control-Expose-Headersor JS can’t read them. Common gotcha for pagination/total-count headers.” - “Preflight cache (
Access-Control-Max-Age) is the perf lever — without it, every non-simple request pays an extra round trip.” - “CDN + CORS:
Vary: Originso different SPA origins don’t share a cached response with the wrong header.”
Cross-references
- Security (CORS-adjacent: CSRF, XSS, cookies): ../17_security/
- HTTP basics: ../../backend/12_protocols/http/
- Backend CORS configuration (server side): ../../backend/06_web_frameworks/
Further reading
- MDN — CORS: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
- WHATWG Fetch — CORS protocol: https://fetch.spec.whatwg.org/#cors-protocol
- web.dev — Cross-Origin Isolation: https://web.dev/articles/cross-origin-isolation-guide
- Jake Archibald — “Same-site and same-origin”: https://jakearchibald.com/2021/same-site-cross-origin/