Fetch: Cross-Origin Requests
Point fetch at a URL on your own site and it just works. Point it at someone else’s site and it usually blows up. That gap is the whole subject of this article.
Try fetching a URL on another domain from your page:
try {
await fetch('http://example.com');
} catch(err) {
alert(err); // Failed to fetch
}
The request fails, and that failure is by design, not a bug in your code.
The idea that decides success or failure is the origin — a triple of protocol, domain, and port. Two URLs share an origin only when all three match.
A cross-origin request is anything sent to a different domain (a subdomain counts), a different protocol, or a different port. Those requests need the remote server to opt in with specific headers. The policy that governs all of this is CORS: Cross-Origin Resource Sharing.
Deciding “same or cross” is exactly what the browser does before every fetch. Edit either URL below and it splits both into the origin triple, then tells you whether a request from the first to the second is cross-origin — and which part broke the match.
Why CORS exists: a short history
CORS is there to keep hostile scripts from stealing your data. To see why the rules look the way they do, it helps to know what the web was like before them.
For years, a script on one site simply could not read the contents of another site. That single rule held the early web’s security together. A script running on hacker.example had no way to reach into your inbox on mail.example. Users could browse without every open tab spying on every other one.
Back then JavaScript had no networking API at all. It was a small language for sprucing up a page — swapping images, validating a form field, little else.
Developers wanted more, though. To reach other servers, people leaned on whatever the browser already allowed, and a handful of clever workarounds grew up around those gaps.
The form trick
A <form> can submit to any URL, on any domain. To avoid navigating away from the current page, people pointed the form at a hidden <iframe> and submitted into that:
<!-- form target -->
<iframe name="iframe"></iframe>
<!-- a form could be dynamically generated and submitted by JavaScript -->
<form target="iframe" method="POST" action="http://another.com/…">
...
</form>
So you could fire a GET or POST at another site with no networking API in sight, because forms send data wherever you tell them. Reading the reply was the problem. The same-origin rule blocks a page from touching the contents of a cross-origin <iframe>, so the response stayed out of reach.
There were ways around even that, involving cooperating scripts on both the page and the iframe. The details are a museum piece now, so we’ll leave those dinosaurs undisturbed.
The script trick (JSONP)
A <script> tag can load its src from any domain — <script src="http://another.com/…"> runs code from another site by design. That opened a second door.
If a site like another.com wanted to hand out data through this door, it used a convention called JSONP — “JSON with padding.” The dance goes like this. Say your page needs the exchange rate from http://another.com:
-
Ahead of time, you define a global function that will receive the data — say
gotRate.// 1. Declare the function to process the rate data function gotRate({ base, rate }) { alert(`base: ${base}, rate: ${rate}`); } -
You add a
<script>whosesrcnames your function in acallbackquery parameter.let script = document.createElement('script'); script.src = `http://another.com/rate.json?callback=gotRate`; document.body.append(script); -
The remote server reads that
callbackname and generates a script that calls it, wrapping the data in a function call:// The expected answer from the server looks like this: gotRate({ base: 'EUR', rate: 1.09 }); -
The browser loads and runs that script,
gotRatefires, and since it’s your function, you now hold the data.
This works and breaks no security rule, because both sides agreed to the exchange. When both ends consent, it’s cooperation, not an attack. A few services still offer JSONP endpoints today, since it runs even on ancient browsers.
Eventually real networking landed in browser JavaScript. Cross-origin calls were forbidden at first. After long debate, they were allowed — on the condition that any capability beyond what the old <form>/<script> tricks already permitted requires the server to grant it explicitly, through headers.
Safe requests
CORS splits cross-origin requests into two buckets:
- Safe requests.
- Everything else.
Safe requests are the easy case, so start there. A request is safe when it meets both conditions:
- Safe method:
GET,POST, orHEAD. - Safe headers — the only custom headers allowed are:
Accept,Accept-Language,Content-Language,Content-Type, and only with the valueapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain.
Anything outside those limits is “unsafe.” A PUT request is unsafe. So is a request carrying a custom API-Key header. So is a POST sending Content-Type: application/json.
The dividing line is historical: a safe request is exactly one you could already have sent with a plain <form> or <script>, with no networking API.
That’s why even a decades-old server should cope with a safe request — the same kind of traffic has been arriving since the web’s early days.
Unsafe requests are different. A DELETE, or a request with a non-standard header, could never come from a bare webpage in the old days. Some servers were written on that assumption, treating such a request as proof it came from a trusted, non-browser client. Letting browsers fire those blind could expose those servers.
So the browser doesn’t fire an unsafe request straight away. It first sends a preflight request asking the server: do you accept this kind of cross-origin call? Unless the server says yes with the right headers, the real request never leaves.
We’ll get to preflight shortly. First, the simple path.
Try the two conditions yourself. Change the method, the Content-Type, or add a custom header, and watch the request flip between “safe” (goes straight out) and “unsafe” (needs a preflight first) — with the exact reason.
CORS for safe requests
Whenever a request is cross-origin, the browser attaches an Origin header automatically. You can’t remove it or fake it — the browser controls it.
Say your page at https://example.com/page requests https://anywhere.com/request. The outgoing headers include:
GET /request
Host: anywhere.com
Origin: https://example.com
...
Notice the Origin header is the origin only — protocol, domain, and port — never the path.
The server reads Origin and decides. If it’s willing to serve this caller, it adds Access-Control-Allow-Origin to the response, set to either the exact allowed origin or * (any origin). Match, and the browser hands the response to your JavaScript. No match, and fetch rejects with an error even though the server actually replied.
The browser is the enforcer in the middle:
- It guarantees the correct
Originrides along with every cross-origin request. - It inspects the response for a matching
Access-Control-Allow-Origin. Present and matching → your code sees the body. Missing or wrong → error, and your code never touches the data.
← checks Access-Control-Allow-Origin
A permissive response looks like this:
200 OK
Content-Type:text/html; charset=UTF-8
Access-Control-Allow-Origin: https://example.com
Play the browser’s part in that final check. Below, your page is at https://example.com. Set the value the server puts in Access-Control-Allow-Origin, toggle credentials, and see whether the browser hands your script the body or rejects with an error — including the wildcard-with-credentials trap.
Response headers
For a cross-origin request, your JavaScript can read only a short list of “safe” response headers by default:
Cache-ControlContent-LanguageContent-LengthContent-TypeExpiresLast-ModifiedPragma
Reach for any other response header and you get an empty value, as if it weren’t there.
To open up more, the server sends Access-Control-Expose-Headers listing the extra header names it’s willing to reveal, comma-separated:
200 OK
Content-Type:text/html; charset=UTF-8
Content-Length: 12345
Content-Encoding: gzip
API-Key: 2c9de507f2c54aa1
Access-Control-Allow-Origin: https://example.com
Access-Control-Expose-Headers: Content-Encoding,API-Key
With that header in place, your script may now read Content-Encoding and API-Key off the response, on top of the seven always-safe ones.
“Unsafe” requests
fetch can use any HTTP method — not only GET and POST, but PATCH, PUT, DELETE, and the rest.
Long ago, no one pictured a webpage issuing those. Some web services still read a non-standard method as a hint that the caller isn’t a browser, and weigh that when granting access. To avoid tricking such a server, the browser doesn’t send an unsafe request cold. It first sends a preliminary preflight request to ask permission.
A preflight uses the OPTIONS method, carries no body, and adds three headers:
Access-Control-Request-Method— the method the real request will use.Access-Control-Request-Headers— a comma-separated list of the unsafe headers the real request will carry.Origin— where the request comes from (such ashttps://example.com).
If the server agrees, it replies with an empty body, status 200, and these headers:
Access-Control-Allow-Origin— either*or the exact requesting origin.Access-Control-Allow-Methods— the allowed method(s).Access-Control-Allow-Headers— the allowed header(s).- Optionally
Access-Control-Max-Age— how many seconds the browser may cache this permission, so it can skip the preflight on later matching requests.
OPTIONS + Access-Control-Request-*
200 + Access-Control-Allow-*
PATCH + Origin
200 + Access-Control-Allow-Origin
Let’s walk a concrete example: a cross-origin PATCH (commonly used to update part of a resource).
let response = await fetch('https://backend.io/inventory', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': 't0ken'
}
});
Three separate things make this unsafe — any one alone would be enough:
- The method is
PATCH. Content-Typeisapplication/json, which isn’t among the three safe values.- There’s a custom
X-Auth-Tokenheader.
Step 1 — the preflight request
Before touching the real request, the browser sends this on its own:
OPTIONS /inventory
Host: backend.io
Origin: https://example.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: Content-Type,X-Auth-Token
- Method:
OPTIONS. - Path: identical to the real request,
/inventory. - Cross-origin headers:
Origin— the source origin.Access-Control-Request-Method— the method to come.Access-Control-Request-Headers— the unsafe headers to come, comma-separated.
Step 2 — the preflight response
To approve, the server returns status 200 with:
Access-Control-Allow-Origin: https://example.comAccess-Control-Allow-Methods: PATCHAccess-Control-Allow-Headers: Content-Type,X-Auth-Token
That clears the way. Anything missing, and the browser stops here with an error.
A server that expects a variety of methods and headers later on can approve them all up front rather than negotiate one at a time:
200 OK
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: PUT,PATCH,DELETE
Access-Control-Allow-Headers: X-Auth-Token,Content-Type,If-Modified-Since,Cache-Control
Access-Control-Max-Age: 86400
The browser now sees PATCH inside Access-Control-Allow-Methods, and both Content-Type and X-Auth-Token inside Access-Control-Allow-Headers, so it proceeds to the real request.
Because Access-Control-Max-Age is set to 86400, the browser caches this permission for one day. Within that window, any later request that fits the cached allowances skips the preflight and goes straight out.
Step 3 — the actual request
With the handshake passed, the browser sends the real request. From here it behaves like the safe-request path. It still carries Origin, because it’s still cross-origin:
PATCH /inventory
Host: backend.io
Content-Type: application/json
X-Auth-Token: t0ken
Origin: https://example.com
Step 4 — the actual response
The server must add Access-Control-Allow-Origin to this response too. Passing the preflight doesn’t excuse it — the real response needs its own permission header:
Access-Control-Allow-Origin: https://example.com
Only then can your JavaScript read the real response.
Credentials
By default, a cross-origin request from JavaScript sends no credentials — no cookies, no HTTP authentication.
That runs against normal HTTP habits. A plain request to http://site.com usually carries every cookie for that domain. Cross-origin requests made through JavaScript methods are the deliberate exception.
Concretely, fetch('http://another.com') sends no cookies — not even ones that belong to another.com itself.
Why hold back? A request with credentials is far more dangerous than one without. Let it through, and the script can act as the logged-in user and read anything their session unlocks.
So credentials stay off unless the server clearly signs up for them. On the browser side, you turn them on with the credentials option:
fetch('http://another.com', {
credentials: "include"
});
Now fetch sends another.com’s cookies along with the request.
For the server to accept a credentialed request, it must add Access-Control-Allow-Credentials: true alongside Access-Control-Allow-Origin:
200 OK
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Credentials: true
Summary
To the browser, cross-origin requests come in two flavors: “safe” and everything else.
A safe request satisfies both of these:
- Method:
GET,POST, orHEAD. - Headers, limited to:
AcceptAccept-LanguageContent-LanguageContent-Type, set toapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain.
The deep reason: safe requests were always possible through <form> and <script> tags, while unsafe ones were off-limits to browsers for years.
The practical result: safe requests go out immediately with an Origin header, while unsafe ones trigger a preliminary OPTIONS “preflight” that asks the server for permission first.
Safe requests:
- → The browser sends the
Originheader. - ← Without credentials (the default), the server must set:
Access-Control-Allow-Originto*or the exactOrigin.
- ← With credentials, the server must set:
Access-Control-Allow-Originto the exactOrigin(never*),Access-Control-Allow-Credentialstotrue.
To expose any response header beyond Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, and Pragma, the server lists it in Access-Control-Expose-Headers.
Unsafe requests get a preflight before the real one:
- → The browser sends an
OPTIONSrequest to the same URL, with:Access-Control-Request-Method— the method it wants to use.Access-Control-Request-Headers— the unsafe headers it wants to send.
- ← The server responds with status 200 and:
Access-Control-Allow-Methods— the allowed methods,Access-Control-Allow-Headers— the allowed headers,Access-Control-Max-Age— optional seconds to cache the permission.
- Then the real request goes out, and the safe-request rules apply to it.