How CORS works
Your page on app.example.com calls fetch() on api.example.com. The browser knocks first, the API sends back a guest list, and your code gets the reply only if your origin is on it.
The browser pins your origin on the call
The browser works like a doorman between your page and every other site. Your page and the API live on different hosts. So the doorman pins a name badge on the call.
An origin is scheme, host and port. All three must match, or the call is cross-origin. The browser adds Origin: https://app.example.com. Origin is a forbidden header, so your code cannot set or fake it.
fetch('https://api.example.com/orders', {
method: 'PUT',
credentials: 'include', // send cookies
body: JSON.stringify(order),
});
A risky call knocks first
A PUT can change data. So the doorman first knocks on the API's door and asks: may I bring this in?
This knock is the preflight, an OPTIONS request that never carries cookies. Any method but GET, HEAD or POST sends one. So does a header off the safelist, such as Authorization, or a Content-Type of application/json. The answer needs a 2xx status and matching Access-Control-Allow-* headers.
OPTIONS /orders Origin: https://app.example.com Access-Control-Request-Method: PUT 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: PUT Access-Control-Allow-Credentials: true
The API runs the PUT and clips on its guest list
The API says yes, so the real PUT goes out. It carries your membership card: your cookies for the API. The API saves the order and replies with its guest list clipped on.
With credentials: 'include', the real request sends cookies, but the preflight never does. The guest list is the Access-Control-Allow-Origin header. The order is already saved, whatever the browser decides next.
The server only writes the list. The browser enforces it.
The browser reads the list before your code sees a byte
The doorman holds the reply at the door. He compares the guest list with your badge.
The check passes when Access-Control-Allow-Origin equals the Origin exactly, or is *. The header holds one origin, not a list. An API that allows several echoes back the one it got and adds Vary: Origin. Cookies make it stricter: the reply also needs a members welcome stamp.
| The list says | No cookies | With cookies |
|---|---|---|
| your exact origin | yes | yes with the stamp |
* | yes | no |
| another origin, or nothing | no | no |
Your code gets the reply, or a bare error
On the list, the doorman hands over the box. Off the list, he keeps it, and your code never learns why.
On the list
fetch() resolves. You read the body, the status and a few safe headers such as Content-Type. Any other header must be named in Access-Control-Expose-Headers.
Off the list
fetch() rejects with a TypeError. The error holds no reason. Only the browser console says what failed.
Why the browser guards the reply
- Your cookies ride along. Without the check, any page you open could read your bank with your login.
- Old servers never agreed to share. Servers built before CORS do not know it exists. So the default is no, and an API opts in with headers.
- Forms could always send. An HTML form could already POST to any site. So those calls skip the knock. The knock guards only what a form cannot do.
Before you rely on it
- catchCORS hides the reply. It does not stop the request. A plain
GETor formPOSTskips the knock, so the server runs it anyway. Andcurlignores CORS. Guard writes with auth and CSRF defenses such asSameSitecookies. - ask nextDoes
mode: 'no-cors'fix the error? The error goes away, and so does the data. You get an opaque response: status 0, no headers and a body your code cannot read. - ask nextWhy does a server crash look like a CORS error? An error page from a proxy or crash handler often lacks the CORS headers. With no guest list, the check fails, so the console blames CORS.
- ask nextCan the browser skip the knock next time? Yes, for a while.
Access-Control-Max-Agecaches the answer. The default is 5 seconds. Chromium caps it at 2 hours, Firefox at 24 hours. - simplifiedOne page, one API, no redirects. The
nullorigin and wildcards inAllow-Methodsare left out. - sourcesThe Fetch Standard, CORS protocol, MDN's CORS guide, Access-Control-Max-Age and fetch().
- not verifiedHow often error pages lack the CORS headers. It depends on your framework and proxy.