Back to Networking
Networking
medium
mid

What is CORS and how does it work in the browser?

CORS (Cross-Origin Resource Sharing) is the browser mechanism that decides whether a page on origin A is allowed to read responses from origin B. The browser sends the request, but won't expose the response to JS unless the server returns the right Access-Control-Allow-* headers. For non-simple requests (custom headers, methods like PUT/DELETE) the browser first sends an OPTIONS 'preflight' to ask permission. CORS is a browser-only enforcement — server-to-server calls bypass it entirely.

8 min read·~5 min to think through

CORS is a browser security feature that controls when JavaScript on one origin can read a response from another origin. It's not a server-side firewall — the request still hits your server, but the browser blocks the response from being seen by JS unless the server opts in via headers.

Why it exists

Without CORS, a malicious page evil.com could fetch('https://bank.com/balance') using the user's cookies and read the response. The Same-Origin Policy (SOP) blocks that by default. CORS is the controlled way to opt in to cross-origin reads.

Origins

An origin = scheme + host + port. https://a.com and https://a.com:8080 are different origins. https://a.com and http://a.com are different origins.

Simple vs preflighted requests

Simple requests (no preflight) require:

  • Method: GET, HEAD, or POST.
  • Only CORS-safelisted headers (Accept, Content-Language, Content-Type with one of application/x-www-form-urlencoded, multipart/form-data, text/plain).
  • No ReadableStream body.

Anything else triggers a preflight OPTIONS request first.

Simple flow

ts
1. Browser → Server:
   GET /data HTTP/1.1
   Origin: https://app.com

2. Server → Browser:
   200 OK
   Access-Control-Allow-Origin: https://app.com

3. Browser exposes body to JS.

If Access-Control-Allow-Origin is missing or doesn't match → browser blocks JS access (but the request was made — important for non-idempotent endpoints).

Preflight flow

ts
1. Browser → Server:
   OPTIONS /data
   Origin: https://app.com
   Access-Control-Request-Method: PUT
   Access-Control-Request-Headers: Authorization, Content-Type

2. Server → Browser:
   200 OK
   Access-Control-Allow-Origin: https://app.com
   Access-Control-Allow-Methods: GET, POST, PUT, DELETE
   Access-Control-Allow-Headers: Authorization, Content-Type
   Access-Control-Max-Age: 86400

3. Browser sends the real PUT.

Access-Control-Max-Age caches the preflight result (e.g., 24h), avoiding the OPTIONS roundtrip on every subsequent request.

Credentials (cookies, Authorization)

By default, cross-origin fetch does not send cookies. To include them:

js
fetch(url, { credentials: 'include' });

The server must then respond with:

ts
Access-Control-Allow-Origin: https://app.com         (NOT *)
Access-Control-Allow-Credentials: true

* is rejected with credentials — you must echo the specific origin.

Common server config (Express example)

js
import cors from 'cors';
app.use(cors({
  origin: ['https://app.com', 'https://staging.app.com'],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400,
}));

What CORS does NOT do

  • It doesn't stop the request from reaching the server — only the response from being read.
  • It doesn't apply to <img>, <script src>, <link>, or form submissions — those have been cross-origin since forever (one reason CSRF exists).
  • It doesn't apply server-to-server. Your Node backend can call any URL; CORS is browser-enforced.

Frequent debugging patterns

SymptomCause
"blocked by CORS policy: No Access-Control-Allow-Origin"Server didn't send the header.
Works for GET, fails for PUT/DELETEServer returns CORS headers on the actual response but not on the OPTIONS preflight.
"credentials flag is true, but Access-Control-Allow-Origin is *"Echo the specific origin, don't use * with credentials.
Works in Postman/curl, fails in browserPostman doesn't enforce CORS. The issue is browser-only.
OPTIONS returns 404Server routing doesn't handle OPTIONS — add a catch-all or use a CORS middleware.

Beyond CORS

  • CORP (Cross-Origin-Resource-Policy): opt-in per resource — "only same-origin can embed this image."
  • COEP/COOP: enable crossOriginIsolated (needed for SharedArrayBuffer, high-resolution timers).
  • SameSite cookies: complementary CSRF defense, independent of CORS.

Follow-up questions

  • What's the difference between simple and preflighted requests?
  • Why can't Access-Control-Allow-Origin be * with credentials?
  • How does CORS interact with SameSite cookies?
  • What's CORP/COEP/COOP and when do you need them?

Common mistakes

  • Setting Access-Control-Allow-Origin: * with credentials: include — browser rejects it.
  • Forgetting to handle OPTIONS in the server router — preflight 404s, real request never fires.
  • Disabling CORS in the browser/dev tools to 'test' — masks the real issue, breaks in production.
  • Echoing back the Origin header without validating against an allowlist — opens CSRF/data exfiltration.
  • Setting Access-Control-Allow-Origin only on success responses — errors also need it for fetch().catch() to see the body.
  • Thinking CORS prevents the request — it prevents the *response* from being read.

Performance considerations

  • Preflight requests add a roundtrip per origin/method/header combination. Mitigate with Access-Control-Max-Age (cache for hours/days), and design APIs so the common path is simple (POST with text/plain JSON, no custom headers — though this conflicts with proper Content-Type). For high-frequency cross-origin APIs, prefer same-origin (proxy through your own server, or use a subdomain with shared cookies).

Edge cases

  • Redirects: the browser re-runs CORS for each hop; preflighted requests don't allow redirects to a different origin.
  • Opaque responses (mode: 'no-cors'): you can fetch them, but JS sees nothing — useful for cache prewarming.
  • Access-Control-Expose-Headers: by default, only safelisted response headers are readable from JS. Custom headers (X-Total-Count) must be explicitly exposed.
  • Subdomain isolation: app.example.com and api.example.com are different origins; cookies need Domain=.example.com to be shared.
  • Service worker fetches inherit CORS rules — credentials, modes must be set explicitly.

Real-world examples

  • Public APIs (GitHub, Stripe) set Access-Control-Allow-Origin: * — they don't accept cookie credentials so * is safe.
  • Internal APIs (app.company.com → api.company.com) echo specific origins and use credentials.
  • CDNs serving fonts/images need CORP headers so they can be embedded across origins.

Senior engineer discussion

Seniors should explain CORS as a browser-only opt-in (not server protection), distinguish simple vs preflighted, understand the credentials interaction, and know when to use a server-side proxy instead. Bonus: knowing about COOP/COEP/CORP for SharedArrayBuffer / Spectre mitigation, and that SameSite cookies do a different job (CSRF) than CORS (data exfiltration via JS).

Related questions