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.
On this page
The picture version
Five pictures for a reader who has only ever seen the red console error. The prose below fills in the seams the pictures skip.
1 · The problem
Two tabs open. One of them wants to read the other’s mail.
2 · The surprising part
For a simple request, the browser doesn’t block it. It blocks the answer.
3 · Where the line is drawn
Anything a plain HTML form could already send, servers have always had to survive.
4 · The preflight
An extra round trip that buys a veto.
5 · Keep this card
The whole thing on one index card.
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 — Cross-Origin Resource Sharing.
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
call fetch("https://yourbank.com/api/accounts") and read the reply — your
balances, your transaction history, whatever that API hands a logged-in user.
Two defences are doing two different jobs here, and it’s worth separating them
now rather than later. Modern fetch doesn’t attach cookies to a cross-origin
request unless the JS explicitly asks — credentials defaults to
"same-origin" — and the shapes that do carry cookies automatically, like a
form post or an image tag, were never governed by these rules in the first
place. Stopping those is
CSRF
defence’s job. What the same-origin policy stops is the reading.
This is why the browser enforces the same-origin policy:
JS running on origin A cannot, by default, read responses from origin B.
An origin
here is the triple (scheme, host, port), which is why https://api.foo.com
and https://foo.com count as different ones.
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.
Wherever JavaScript needs to read the response from another origin, CORS is
the gate it goes through. (Plenty of other cross-origin traffic — images,
<script> tags, form posts, plain navigations — predates the rule and isn’t
governed by it, which is exactly why CSRF is still a thing.)
It also shows up the first time you try to call a model provider’s API straight from a webpage: some providers send CORS headers and it works, and some don’t, in which case the browser refuses to hand your JS the response and you need a backend of your own. Worth knowing which situation you’re in before you design around it.
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
Picture to keep: a bouncer standing inside your browser, not at the server’s door — he’ll carry the message over and bring the reply back, but he won’t hand the reply to the page unless the reply has a note saying “this page may read me.” Where the bouncer analogy breaks: for the more dangerous request shapes he doesn’t deliver first and check later, he goes and asks permission before carrying the message at all.
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
Follow the design forward from the bank tab, one problem at a time.
Naive rule: the browser blocks all cross-origin reads, full stop. That
kills the attack — but it also kills your own SPA on app.example.com
calling api.example.com, which is most of the modern web. So the rule needs
an exception, and the only party who can safely grant it is the server being
called. Hence a response header: Access-Control-Allow-Origin.
Simple requests: allow it, then check the reply. 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: for a simple request,
CORS doesn’t stop the request from happening. It stops the page from
learning what came back. If a GET or a form-shaped POST has side effects,
the damage is already done before the browser checks anything — which is why
servers also need
CSRF
protection (e.g. SameSite cookies, CSRF tokens). CORS and CSRF defenses are
neighbors, not substitutes.
But “check afterwards” can’t be extended to a PUT or a DELETE. The
standard account of why the line is drawn where it is: requests a plain HTML
form could already produce have been reaching servers cross-origin since
before the same-origin policy existed, so servers have always had to cope
with them. Anything beyond that shape — custom headers, content types other
than the basic three, methods like PUT/DELETE — is a new capability, and
sending it first and checking later would hand attackers a new weapon rather
than deny them one. Fix: for those, the browser asks first. That’s the 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)
But now every write costs two round trips. Fix: Access-Control-Max-Age
lets the browser cache that “yes” so it isn’t re-asking before every call.
Browsers cap how long they’ll honor it — the Fetch standard explicitly permits
an imposed limit, and the actual caps differ by browser and have moved over
time, so check your targets rather than memorising a number.
And one last hole: the cookies. fetch doesn’t attach cookies to a
cross-origin request unless the JS asks for them with
credentials: "include". Even then, the browser only hands the response to
the page if the server replies with
Access-Control-Allow-Credentials: true — and in that case the server may
not answer with Access-Control-Allow-Origin: *; it has to name the
specific origin. That’s not a quirk; it’s the whole point. Wildcard plus
cookies would re-open the bank-tab attack. (An Authorization header is a
different animal: it isn’t attached automatically by the browser at all, but
setting it yourself makes the request non-simple, so it triggers a preflight
and has to appear in Access-Control-Allow-Headers.)
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.
You started with CORS = same-origin policy + a server-side opt-in via response headers. What did walking the design add? — + the opt-in is enforced by the victim's browser, on the read. That’s why the request still
reaches your server, why curl doesn’t care, and why side-effecting
endpoints still need CSRF defenses that CORS was never going to give them.
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 —
SameSite ≈ "should this cookie ride along on cross-site requests?"—Strictsays never,Laxallows it on top-level navigations (so a link to your site still logs you in),Noneallows it everywhere, and only in combination withSecure. A blunter and 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, “CORS protocol” section — go here for the normative answer to “what exactly counts as a simple request, and when is a preflight required?” (CORS had a standalone W3C Recommendation from 2014 until it was retired in 2020; Fetch is where it is normatively defined now).
- MDN’s CORS page — the practical answer to “which header do I actually set to make this work,” with worked simple vs. preflighted examples.
- OWASP’s CSRF page — the rabbit hole: what the attack CORS doesn’t stop actually looks like, and what does stop it.