bytesize

Why fetch works in curl but the browser blocks it

·7 min read

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.

Almost every explanation of CORS gets the shape of this wrong. The server didn't reject anything. Your browser did. And it waited until the response was already in its hands before doing it.

#What “same origin” actually means

Before the mechanism makes sense, the vocabulary has to. An origin is the triple (scheme, host, port). Two URLs are the same originonly if all three match. Path doesn't count. Subdomains don't match. And yes, http vs https on the exact same hostname is cross-origin — TLS changes the trust boundary. Hover any row below to see which part differs.

origin = (scheme, host, port) · hover any row
origin = (scheme, host, port) · hover any rowbase · https://app.example.com
urlschemehostportpathverdict
basehttpsapp.example.com(443)/
http://app.example.comhttpapp.example.com(80)/cross
https://app.example.com:8080httpsapp.example.com8080/cross
https://api.example.comhttpsapi.example.com(443)/cross
https://example.comhttpsexample.com(443)/cross
https://app.example.com/adminhttpsapp.example.com(443)/adminsame
Each URL below is compared to the base. Hover or tab to any row to see which component of the tuple differs and why it moves that URL cross-origin.

The same-origin policyis the browser's default. JS running in one origin can't read a response from another. Loading assets across origins — an <img>, a <script>, a stylesheet — has always been allowed, which is why the restriction feels arbitrary until you notice what is forbidden: reading a response from JS. CORS is the opt-in mechanism for punching holes in that wall.

#The browser is the enforcer, not the server

Here's the shape most posts miss. Step through the widget below, then flip the Access-Control-Allow-Originpill and step through it again. The network trace doesn't change. The only thing that changes is what JS gets back at the end.

RequestJourney · same network, different outcomes
RequestJourney · same network, different outcomesstep 6/6 · Allow-Origin off
JSbrowsernetworkserverbrowser ↔ JS boundaryfetch(url)GET /me · Origin: app.example.comhandler runs200 OK · bodyTypeErrorDevToolsNetworkGET /me · 200 OK · 142 BConsole✗ TypeError: Failed to fetch
6 / 6
The browser reads the response headers, looks for an Access-Control-Allow-Origin matching the page's origin, doesn't find one, and silently discards the body. JS sees only TypeError: Failed to fetch.

Walk that back slowly. The fetch left the browser. The request reached the server. The server ran its handler — the same code it runs for any other client. It returned 200 OK with a real body. The response arrived in the browser. Only then, afterthe whole round trip, did the browser look at the response headers, fail to find an invitation for your page's origin, and throw the body away.

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. CORS is a contract between a server's response headers and a browser'sJS sandbox. Outside the browser, there's no one listening.

And the point of the contract isn't to stop people from reaching your server. It's to stop a page at evil.com, open in your user's tab, from using 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.

#Not every request even leaves the browser

The “server runs, browser redacts” rule holds for simple requests — roughly, what an HTML form could have sent without JS: a GET, or a plain POST with one of three old content types (text/plain, application/x-www-form-urlencoded, or multipart/form-data) and no custom headers. For anything else, the browser sends a second, silent request first — an OPTIONS, asking permission. If permission is denied, the real request is never sent.

Flip the controls below and watch the verdict change.

RequestClassifier · will this preflight?
RequestClassifier · will this preflight?simple · no preflight
method
Content-Type
(no body on this method)
headers
simple request · no preflight
The request is safelisted. The browser sends it directly — the server runs the handler, and only the response is subject to the CORS check.

The preflight carries Origin, the method the real request will use, and any non-safelisted headers it plans to send. The server answers with a matching Access-Control-Allow-Methods and Access-Control-Allow-Headers, and only thendoes the browser let the real request go. If any of those don't line up, the real POST, DELETE, or PATCH never reaches your backend. This is the one case where CORS genuinely blocks the wire, not just the read.

The practical consequence is blunt: switching a request from text/plain to application/json, or adding an Authorizationheader, doesn't just change whether JS can read the reply — it changes whether the POST arrives at all. Every modern JSON API preflights every call. Browsers cache the OPTIONS result briefly (seconds to a couple of hours) via Access-Control-Max-Ageso the handshake doesn't repeat for every request.

#Cookies don't tag along by default

fetch to a cross-origin URL does not send cookies or HTTP auth unless you ask. You opt in; the server opts in separately. Both sides have to agree.

clientjavascript
// client opt-in
await fetch('https://api.other.com/me', {
  credentials: 'include',
});

// server must respond with, exactly:
//   Access-Control-Allow-Origin: https://app.example.com
//   Access-Control-Allow-Credentials: true
//
// not '*' — wildcard + credentials is an error.

Notice what's missing: the wildcard. Access-Control-Allow-Origin: * is illegal once credentials are in play, and the browser will reject the response even if the server sends it. The origin has to be named— which is the whole point. A wildcard would mean “any site can read this response using this user's cookies,” which is the thing CORS exists to prevent.

#Reading a failing network tab

Next time a red TypeErrorshows up in the console, the Network tab is the first place to look. Did the request reach the server? Usually yes — you'll see a 200, or at least some response. That alone tells you the server isn't refusing; the response headers are.

Three things to check, in order. Which origin is your page? Is the server returning Access-Control-Allow-Originmatching that origin exactly? And if there's an OPTIONS row before your real request, does thatresponse approve the method and headers you're about to send? Almost every CORS failure is one of those three, in that order.

The browser isn't being rude. It's doing the thing that keeps every other site you're logged into — your bank, your email, your account settings — from being read by whatever tab you just opened. The TypeError is that protection, showing up for you too.