Web security · explained simply

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.

Origin→ plain?→ OPTIONS→ Allow-Origin→ cookies→ Max-Age→ your code
Origin app.example your page the browser
1

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.

URLSame origin as app.example?
https://app.example/orderssame The path does not count.
http://app.exampleother Another scheme.
https://api.exampleother Another host.
https://app.example:8443other Another port. The default is 443.
guest = your page bouncer = the browser name tag = Origin header
your request PUT /orders application/json not safelisted OPTIONS first call ahead
2

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.

RequestResult
GET, HEAD or POST, no custom headerswalks in
Content-Type: text/plain, multipart/form-data or application/x-www-form-urlencodedwalks in
Content-Type: application/jsoncalls ahead
PUT, DELETE or PATCHcalls ahead
Authorization or any custom headercalls ahead
walk-in = CORS-safelisted request call ahead = CORS preflight

A plain request is checked after it runs. Anything else is checked before.

page browser api.example 1 fetch() PUT 2 OPTIONS 3 204 Allow-* 4 PUT /orders 5 200 Allow-Origin 6 resolves
3

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
house rules = Allow-Methods + Allow-Headers
https:// app.example name tag guest list https:// app.example app.example/ *.example Allow-Origin
4

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-OriginResult
https://app.examplepass
*pass Only without cookies.
https://app.example/fail Trailing slash.
https://*.examplefail No wildcard hosts.
https://app.example, https://admin.examplefail One value only.
guest list = Access-Control-Allow-Origin open door = *
cookie include your code asks true Allow-Credentials the club agrees
5

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();
});
member card = cookies members welcome = Allow-Credentials: true
OPTIONS + PUT PUT only OPTIONS + PUT Max-Age 0 s 7200 s one band per URL
6

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 requestResult
Same URL and headers, inside Max-Agewalks in
Adds a header the entry does not listcalls ahead
/orders/2 instead of /orderscalls ahead A new URL.
After Max-Age runs outcalls ahead
wristband = preflight cache entry wristband time = Access-Control-Max-Age
pass fail body + 7 headers TypeError no status
7

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.

other site POST browser runs api.example reply ran, then hidden
why

Why the browser guards the door, not the server

  1. 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.
  2. The server still runs plain requests. A plain POST from any site reaches your server and runs. The browser only hides the reply. CSRF protection and auth checks stay your job.
  3. Only browsers check. curl, Postman and other servers never read Access-Control-*. They can also send any Origin they like.

CORS guards the reader, not the server.

Before you rely on it