Why does CORS exist?
CORS isn't there to keep you out of an API — it's there to stop a webpage you're visiting from quietly using your logged-in cookies on a different site. The whole design only makes sense once you see that.
Why it exists
Every web engineer has had this moment: you fetch() an API from your
frontend, the request looks fine, but the browser refuses it with a wall of red
console text mentioning CORS.
The natural reaction is “why is the browser blocking my own request to my own
server?”
The answer is the part that’s almost always skipped in the error message: CORS is not protecting the server. It’s protecting you, the user, from the page you’re currently looking at.
Here’s the threat model. You’re logged into your bank in one tab — your bank’s
session cookie is sitting in the browser. You open another tab and visit
evil.example. Without any browser rules, JavaScript on evil.example could
just call fetch("https://yourbank.com/api/transfer?..."). The browser, being
helpful, would attach your bank’s cookies to the request because that’s what
browsers do for yourbank.com. The bank would see a perfectly authenticated
request and act on it. Done — your money is gone, and you didn’t click
anything other than a link.
This is why the browser enforces the same-origin policy:
JS running on origin A cannot, by default, read responses from origin B.
Origin here means the triple (scheme, host, port) — https://api.foo.com
and https://foo.com are different origins.
CORS is the opt-in escape hatch on top of that policy. It lets a server on origin B say “actually, JS from origin A is welcome to read my responses.” The default is no; CORS is how the server says yes.
Why it matters now
Almost every modern app is split across origins: a SPA on app.example.com
talking to an API on api.example.com, a static site on a CDN calling a
backend somewhere else, a third-party widget embedded in a customer page.
Every one of those crossings goes through CORS.
It also shows up the moment you build anything with an
LLM in the browser — calling a model
provider’s API directly from a webpage runs straight into CORS, which is
exactly the point. The browser is making sure a random page can’t burn your
API credits using a key that leaked into localStorage.
If CORS feels annoying, it’s because the threat it blocks is invisible: you never see the requests it stopped, only the legitimate ones it got in the way of.
The short answer
CORS = same-origin policy + a server-side opt-in via response headers
The browser refuses cross-origin reads by default. CORS is the protocol where
the server explicitly says “this origin may read this response,” using HTTP
headers like Access-Control-Allow-Origin. The browser, not the server, is
the one enforcing the result.
How it works
Two things are happening, and conflating them is what makes CORS confusing.
1. Simple requests. A “simple” cross-origin request — roughly, a GET or
basic POST with safe headers — actually goes out to the server. The server
processes it and returns a response. Then the browser looks at the response
headers. If Access-Control-Allow-Origin doesn’t include the calling origin,
the browser throws away the response before the JS can read it. The
request hit the server; the JS just isn’t allowed to see the answer.
This is worth pausing on, because it surprises people: CORS doesn’t stop the request from happening. It stops the page from learning what came back. For side-effecting endpoints, that’s not enough on its own — which is why servers also need CSRF protection (e.g. SameSite cookies, CSRF tokens). CORS and CSRF defenses are neighbors, not substitutes.
2. Preflighted requests. Anything more dangerous — custom headers, content
types other than the basic three, methods like PUT/DELETE — triggers a
preflight. Before the real request, the browser sends an OPTIONS
request asking “may JS on origin A make a PUT to this URL with these
headers?” The server answers with Access-Control-Allow-* headers. Only if
the answer is yes does the browser send the real request at all.
A preflight in cartoon form:
Browser → server: OPTIONS /api/things
Origin: https://app.example
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: x-auth
Server → browser: 204 No Content
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: x-auth
Access-Control-Max-Age: 600
Browser → server: PUT /api/things (the real request)
Max-Age lets the browser cache that “yes” so it isn’t re-asking before every
call. The exact upper bound varies by browser — I don’t have a current
authoritative number to quote here, and it has changed over time, so check
your target browsers if it matters.
Credentials are a separate switch. Cookies and Authorization headers
aren’t sent on cross-origin requests unless the JS explicitly asks
(credentials: "include" in fetch) and the server explicitly allows it
(Access-Control-Allow-Credentials: true). And when credentials are in play,
the server cannot reply with Access-Control-Allow-Origin: * — it has to
echo the specific origin. That’s not a quirk; it’s the whole point. Wildcard
plus cookies would re-open the bank-tab attack.
The seam to notice: enforcement lives in the browser. A non-browser client
(curl, your backend, a mobile app) ignores CORS entirely, because the threat
model — “an attacker page running in the user’s authenticated session” —
doesn’t apply there. CORS protects browser users, not servers. A server that
treats Access-Control-Allow-Origin as access control has misunderstood what
it does.
Famous related terms
- Same-origin policy —
same-origin policy = (scheme, host, port) tuple + "JS can only read its own origin"— the default rule CORS opts out of. - CSRF —
CSRF ≈ "browser sends your cookies, attacker chooses the URL"— the write-side cousin of the threat CORS addresses on the read side. - SameSite cookies — a cookie attribute that tells the browser not to send the cookie on cross-site requests at all. A blunter, often more effective defense than CORS for state-changing endpoints.
- Preflight (
OPTIONS) —preflight ≈ "may I?" before "please do"— the negotiation step for non-simple cross-origin requests. - Origin vs. site — not the same thing.
a.foo.comandb.foo.comare different origins but the same site. SameSite cookies care about site; CORS cares about origin.
Going deeper
- The Fetch Standard (fetch.spec.whatwg.org) — the actual normative spec for CORS lives inside Fetch, not as a standalone document. Worth reading the “CORS protocol” section once just to see how small it is.
- MDN’s “Cross-Origin Resource Sharing (CORS)” page — the most pragmatic reference, with a clear breakdown of simple vs. preflighted requests.
- Any writeup on a real CSRF attack from before SameSite cookies became default — the historical context is what makes the same-origin policy feel obvious instead of arbitrary.