Why fetch fails in browser but works in curl (CORS)
·9 min read
The server already said yes. The browser threw the answer away.
You call an API from the browser. The Network tab shows 200 OK. The Console right below shows TypeError: Failed to fetch. You paste the same URL into a terminal, run curl, and get JSON back. Same URL, same server, same five-second window. One works. The other doesn't.
$ curl https://api.other.com/me
{"ok": true, "name": "you"}
# 200 OK — server answered.await fetch('https://api.other.com/me')
// Uncaught (in promise) TypeError:
// Failed to fetch
// Access-Control-Allow-Origin missing.Your browser rejected it. And it waited until the response was already in its hands before doing so.
Same origin is three things, not one.#
An origin is the triple (scheme, host, port). Two URLs are the same origin only if all three match.
That triple is what the browser checks before it lets one page read another's response. CORS is the opt-in mechanism for relaxing that rule, narrowly.
The browser is the enforcer, not the server.#
Step through the widget below, then flip the server's allow-list toggle and step through it again. The network trace doesn't change. The only thing that changes is what JS gets back at the end.
CORS does not block the request. It blocks the response.
That's why curl“works.” curl doesn't parse Access-Control-*headers. Neither does Postman, or Python's requests, or your backend calling your other backend. Outside the browser, there's no one listening.
CORS isn't about your server. It's about the user's cookies. A page at evil.com, open in your user's tab, could otherwise use the cookies already in that browser to read a response from bank.com. The browser has the user's authority; CORS is what keeps one page from borrowing it to read another page's data.
Some requests never leave the browser.#
Some requests get this treatment. Others trigger a second request first — an OPTIONS, asking permission, before the real one is allowed to leave. The widget classifies which is which.
The preflight asks the server, in advance, whether the real request is allowed: its method, its custom headers. If the answer doesn't match, the real POST or DELETE never leaves the browser. This is the one case where CORS blocks the wire, not just the read. Browsers cache the OPTIONS result briefly via Access-Control-Max-Ageso the handshake doesn't repeat on every call.
Every CORS failure is one of three things.#
The browser isn't being rude. It's doing the thing that keeps every other tab from reading what you're logged into. The TypeError is that protection, showing up for you too.