Heads up: posts on this site are drafted by Claude and fact-checked by Codex. Both can still get things wrong — read with care and verify anything load-bearing before relying on it.
why → how

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.

Networking intro Apr 29, 2026 · updated Aug 25, 2026 · 9 min read

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.

tab 1 — your bank logged in tab 2 — evil.example you clicked a link its JS calls the bank’s API and wants to read what comes back the default is no script on one origin may not read another’s response CORS is not protecting the server. It protects you from the page you’re on. an origin is the triple (scheme, host, port) — which is why https://api.foo.com and https://foo.com are different ones
Keep one distinction from the start: modern fetch doesn’t attach your cookies to a cross-origin request unless the script asks, and the shapes that do carry them automatically — a form post, an image tag, a navigation — were never covered by these rules at all. Stopping those is CSRF defence’s job. What this policy stops is the reading.

2 · The surprising part

For a simple request, the browser doesn’t block it. It blocks the answer.

your page’s JS GET the browser sends it the server runs it side effects and all response now the browser reads the header thrown away no Access-Control-Allow-Origin naming your page? the JS never sees it. The request hit the server. The page just isn’t allowed to learn what came back. so if a GET or a form-shaped POST has side effects, the damage is done before the browser checks anything — which is why CSRF defences are a separate job
This is the bit that surprises people, and it explains a whole class of confusion: CORS and CSRF protection are neighbours, not substitutes. Plenty of cross-origin traffic — images, <script> tags, form posts, plain navigations — predates the rule and isn’t governed by it at all.

3 · Where the line is drawn

Anything a plain HTML form could already send, servers have always had to survive.

old capability — send, then check a GET a basic POST with safe headers these have been reaching servers cross-origin since before the same-origin policy existed new capability — ask first PUT, DELETE custom headers, other content types sending these first and checking later would hand attackers a new weapon rather than deny them one So for the right-hand column, the browser asks permission before it sends anything.
That is the standard account of why the boundary sits exactly where it does. It isn’t about which methods sound dangerous — it’s about which ones the web was already exposed to before anyone was checking.

4 · The preflight

An extra round trip that buys a veto.

browser server OPTIONS — may I PUT here, with header x-auth? 204 — allow-origin, allow-methods, allow-headers PUT — the real request, only now and Access-Control-Max-Age lets the browser cache that yes, so it isn’t re-asking before every call If the answer is no, the real request is never sent at all.
Browsers cap how long they will honour that cache, and the caps have changed over time — check your target browsers if the extra round trip matters to you. Note the asymmetry with scene 2: here the veto arrives before anything happens, which is the whole reason the preflight is worth its cost.

5 · Keep this card

The whole thing on one index card.

CORS = the same-origin policy + a server-side opt-in via response headers ∴ the browser enforces it, not the server
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 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.

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.

Going deeper