CORS at the Protocol Level — Preflight, Credentials, Exposed Headers
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.
What is the same-origin policy?
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.
Why does CORS exist?
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/jsonThe 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.
Simple vs preflighted requests.
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.
Preflight — what happens?
# 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: 3600If 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.
Credentialed requests — cookies and Authorization.
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:
# MUST be a specific origin, NOT *
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: trueKey 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, andSameSiteis about the site (scheme plus registrable domain), not the origin. A request fromapp.example.comtoapi.example.comis cross-origin, so CORS applies, but same-site, soLaxand evenStrictcookies are sent.
The most common credentialed-CORS bug: server returns Allow-Origin: * for credentials requests. Browser rejects. Fix: server must echo the specific origin.
Exposing response headers.
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-Tokenconst res = await fetch(...);
res.headers.get("X-Total-Count"); // accessible because exposed
res.headers.get("X-Internal-ID"); // null — not exposedCommon 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.
Preflight tuning — Access-Control-Max-Age.
Preflights add latency (extra round trip). Cache them:
Access-Control-Max-Age: 86400 # 24 hoursBrowser 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.
Common CORS errors and their meanings.
| 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.
How does a CDN affect CORS?
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.
Same-origin policy bypasses (legitimate).
- 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).
COOP / COEP / CORP — modern isolation headers.
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
- 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.
Related
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/cors/
Interview angle 6
- “What is CORS relaxing?” - the same-origin policy, which is default-deny for cross-origin reads. The browser still sends the request and the server still processes it - CORS only decides whether JavaScript is permitted to see the response.
- “When does a preflight happen?” - when the request is not “simple”: a
method beyond GET, HEAD or POST, a
Content-Typeoutside the safelist - which includesapplication/json- or any custom header. The browser sends anOPTIONSfirst and only proceeds if the response permits it. - “What do credentials require?” - both sides opting in:
credentials: "include"on the client andAccess-Control-Allow-Credentials: trueon the server. AndAllow-Origincannot be*with credentials, so the server must echo a specific allowlisted origin. - “Your JavaScript cannot read a response header that is definitely there.” -
Access-Control-Expose-Headers. Only a small safelist is readable cross-origin by default, so pagination or total-count headers are invisible until you expose them explicitly. It is the most common CORS surprise after the preflight itself. - “What is the performance lever?” -
Access-Control-Max-Age, which caches the preflight result. Without it every non-simple request pays an extra round trip, so a chatty JSON API doubles its request count for no functional reason. - “What breaks when a CDN is in front?” - a cached response carrying the
wrong
Allow-Origin.Vary: Originmakes the origin part of the cache key so two different front ends cannot receive each other’s headers.