How CORS works
Your page at https://app.example sends a JSON PUT to api.example. The browser stops it at the door and calls ahead, and your code sees the reply only if the server's guest list names your origin.
The browser pins a name tag on your request
Every page has a home address, its origin. When your page asks another origin for data, the browser pins a name tag on the request: Origin: https://app.example.
An origin is the scheme, the host and the port, compared exactly. A same-origin request skips CORS. Origin is a forbidden request header, so your script cannot write or fake it.
| URL | Same origin as app.example? |
|---|---|
https://app.example/orders | same The path does not count. |
http://app.example | other Another scheme. |
https://api.example | other Another host. |
https://app.example:8443 | other Another port. The default is 443. |
A plain request walks in, anything else calls ahead
Some requests look like what an old HTML form could already send. The browser lets those walk straight in. A JSON body, a PUT or an auth header makes the bouncer call ahead first.
The test is the CORS safelist. Each safelisted header value can be at most 128 bytes, and all of them together at most 1,024 bytes. Past that, they count as unsafe. A ReadableStream body always calls ahead.
| Request | Result |
|---|---|
GET, HEAD or POST, no custom headers | walks in |
Content-Type: text/plain, multipart/form-data or application/x-www-form-urlencoded | walks in |
Content-Type: application/json | calls ahead |
PUT, DELETE or PATCH | calls ahead |
Authorization or any custom header | calls ahead |
A plain request is checked after it runs. Anything else is checked before.
The call ahead asks before the PUT leaves
The bouncer phones the club: may app.example send a PUT with a Content-Type header? Only a clear yes lets the real request out.
The preflight is an OPTIONS request with no cookies and no body. The answer needs an ok status (200–299, usually 204) and matching Access-Control-Allow-* headers. A 404, a 401, a redirect or a missing header ends the fetch at message 3.
The price is one extra round trip before the PUT. On an 80 ms link (an example), the PUT leaves 80 ms later.
OPTIONS /orders HTTP/1.1 Host: api.example Origin: https://app.example Access-Control-Request-Method: PUT Access-Control-Request-Headers: content-type HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.example Access-Control-Allow-Methods: GET, PUT, DELETE Access-Control-Allow-Headers: Content-Type Access-Control-Max-Age: 7200 Vary: Origin
The guest list must name your origin exactly
The club writes one name on its guest list: your exact origin, or * for an open door. The bouncer compares it with the name tag, letter for letter.
The browser reads Access-Control-Allow-Origin on the preflight answer and on the real answer. The first yes does not cover the second. A missing header fails. A list fails too, because the header holds one value.
| Allow-Origin | Result |
|---|---|
https://app.example | pass |
* | pass Only without cookies. |
https://app.example/ | fail Trailing slash. |
https://*.example | fail No wildcard hosts. |
https://app.example, https://admin.example | fail One value only. |
Cookies need a yes from both sides
By default, a cross-origin fetch leaves your member card (cookies) at home. Your code must ask to bring it. The club must stamp the answer members welcome.
Your code sets credentials: "include". The answer must carry Access-Control-Allow-Credentials: true, spelled exactly so. Allow-Origin must then name the origin, because * fails. In Allow-Headers, Allow-Methods and Expose-Headers, * becomes a plain name. Authorization never matches *, even without cookies.
The preflight still carries no cookies. So auth middleware that runs on OPTIONS answers 401 and blocks everything. Third-party cookie rules can also keep the card at home.
// Runs before auth, so OPTIONS never meets a 401.
const allowed = new Set(["https://app.example", "https://admin.example"]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (allowed.has(origin)) {
res.set("Access-Control-Allow-Origin", origin);
res.set("Access-Control-Allow-Credentials", "true");
}
res.append("Vary", "Origin");
if (req.method === "OPTIONS") {
res.set("Access-Control-Allow-Methods", "GET, PUT, DELETE");
res.set("Access-Control-Allow-Headers", "Content-Type");
res.set("Access-Control-Max-Age", "7200");
return res.sendStatus(204);
}
next();
});
A yes is remembered for a short while
Calling ahead every time would double the trips. So after a yes, the bouncer gives your page a wristband. While it lasts, matching requests walk straight in.
The browser caches the yes for Access-Control-Max-Age seconds. With no header, that is 5 seconds. Chromium caps it at 7,200 s (2 hours), and Firefox at 86,400 s (24 hours).
One wristband covers one origin, one full URL and one credentials mode. A failed request throws away the wristbands for that URL.
| Next request | Result |
|---|---|
Same URL and headers, inside Max-Age | walks in |
| Adds a header the entry does not list | calls ahead |
/orders/2 instead of /orders | calls ahead A new URL. |
After Max-Age runs out | calls ahead |
Your code gets the reply, or a bare TypeError
On a pass, the bouncer hands your code the reply. On a fail, your code gets nothing to read: no status, no headers, no body.
Pass · the list matched
The promise resolves. You read the body and seven safelisted headers, such as Content-Type. Any other header needs Access-Control-Expose-Headers.
const res = await fetch(url, opts);
res.headers.get("Content-Type");
// "application/json"
res.headers.get("X-Request-Id");
// null, unless exposed
Fail · no match
fetch() rejects with a TypeError. Only the DevTools console names the reason.
try {
await fetch(url, opts);
} catch (err) {
// err is a TypeError:
// no status, no headers.
}
The seven are Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma.
Why the browser guards the door, not the server
- The browser holds your cookies. Without a rule, any site you visit could read your bank's answers with your cookies. So browsers hide cross-origin replies by default. CORS is how a server opts in.
- The server still runs plain requests. A plain
POSTfrom any site reaches your server and runs. The browser only hides the reply. CSRF protection and auth checks stay your job. - Only browsers check.
curl, Postman and other servers never readAccess-Control-*. They can also send anyOriginthey like.
CORS guards the reader, not the server.
Before you rely on it
- catchEchoing back any
OriginwithAllow-Credentials: truelets every site read your users' data with their cookies. Check the origin against an allowlist. SendVary: Origin, so a CDN does not serve one site's answer to another. - ask nextWhy does curl work when the browser fails? Only browsers enforce CORS. A working
curlproves the server runs, not that CORS is set. - ask nextCan I just use
mode: "no-cors"? You get an opaque response: status0, no headers and a body your code cannot read. It fits only fire-and-forget calls. - ask nextHow do I skip the preflight completely? Serve the API from the same origin, for example through a proxy at
app.example/api. Then CORS never runs. - ask nextHow do I test my CORS setup? Send the preflight yourself with
curl -i -X OPTIONS, anOriginheader and anAccess-Control-Request-Methodheader. Then read theAccess-Control-*headers that come back. - simplifiedLeft out: redirects, CORS for images, fonts and module scripts, the site partition in the preflight cache, and Private Network Access.
- sourcesThe Fetch Standard (CORS protocol, CORS-preflight fetch, preflight cache, CORS check), and MDN's CORS guide, Access-Control-Max-Age and Access-Control-Allow-Headers pages.