lainlog
back

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.

terminalbash
$ curl https://api.other.com/me
{"ok": true, "name": "you"}
# 200 OK — server answered.
browser · devtoolsjavascript
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.

origin · scheme · host · port
origin · scheme · host · portbase · https://app.example.com
Tap any row. Terracotta marks the part that shifts the URL out of your origin.

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.

request journey · the browser is the gatekeeper
request journey · the browser is the gatekeeperallow-origin missing
step 1 of 6
Press ▸ to walk a cross-origin fetch from the browser to the server and back. Toggle the allow-origin header to compare verdicts.

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.

preflight classifier · request shape decides
preflight classifier · request shape decidessends directly
Watch the request shape decide. Toggle a method, a content-type, or a header — flip credentials to add the cookie constraint.

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.