CORS at the Protocol Level — Preflight, Credentials, Exposed Headers

7 min read source

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/XHR is blocked.
  • Reading from a cross-origin iframe’s contentDocument is 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.comhttps://a.comhttps://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, or POST.
  • Headers are limited to a safelist (Accept, Accept-Language, Content-Language, Content-Type with restricted values).
  • Content-Type (if present) is application/x-www-form-urlencoded, multipart/form-data, or text/plain.
  • No ReadableStream request 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 the Origin header from the request after validating against an allowlist.
  • Both sides opt-in — client credentials: "include" + server Allow-Credentials: true. Either missing → cookies/auth not sent or response blocked.
  • Cookies are subject to SameSite independently — SameSite=Lax cookies 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-Origin from origin is what reaches the browser.
  • Edge caching — if the same URL has different origins (Vary: Origin not 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 with targetOrigin).
  • 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’t window.open and interact with cross-origin windows.
  • Cross-Origin-Embedder-Policy: require-corp — subresources must opt in via Cross-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 Origin header in JS is blocked — browser-controlled. Don’t try.
  • crossorigin attribute on <img>/<script> — enables CORS for the asset. Without it, the image loads but getImageData from <canvas> throws (security). With it, server must return Access-Control-Allow-Origin.
  • Authorization header always triggers preflight — even with simple methods. Cache the preflight with Max-Age.
  • Access-Control-Allow-Origin cannot be a list — single origin, or *. To support multiple origins, server reads Origin request 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' + server Allow-Credentials: true. And Allow-Origin: * is forbidden with credentials — must echo specific origin.”
  • “Custom response headers need Access-Control-Expose-Headers or 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: Origin so different SPA origins don’t share a cached response with the wrong header.”

Cross-references

Further reading