Skip to content

Same-origin policy and CORS

The browser enforces CORS, not the server: it decides whether a page may read a response and, for non-simple requests, whether to send it.

Predict first

Before you read: predict

ExamHall's candidate app and its answers API are on different origins of one site. The candidate is signed in, and the API knows the candidate by its own cookie, which the API's host set. To save an answer, the app runs:

The API answers every request, OPTIONS included, with Access-Control-Allow-Origin: * and Access-Control-Allow-Headers: content-type. It stores an answer whenever a POST arrives, and replies 201.

What does the API's request log show?

Code for this question
fetch(`${API}/attempts/a-7/answers/q4`, {  method: 'POST', credentials: 'include',  headers: { 'Content-Type': 'application/json' },  body: JSON.stringify({ q: 4, text: 'Demand falls' }),});
Pick one

The 30-second version

A page may send cross-origin requests, but reads a response only if the server's CORS headers allow its origin. A simple request (GET, HEAD, or a form or text POST) goes straight out, so the server runs it even when reading fails. Anything else is preflighted with OPTIONS; if that fails, it never leaves. With credentials, * always fails.

The browser enforces CORS, not the server: it decides whether a page may read a response and, for non-simple requests, whether to send it.

How it works

Step 1 of 9

An origin is scheme, host and port. localhost:3000 and localhost:4000 differ; so do localhost and 127.0.0.1 on one port.

Analogy: A boarding school's house post

The housemistress posts every pupil's letters. When a reply comes back she reads its cover note, and hands the reply over only if the note says it may be given to that pupil's house. Parcels and unusual letters wait: she first writes to ask "may Blue House send you this?" and sends nothing until a yes comes back.

In the house post In the browser
a plain letter to a shop is posted at once; the shop fills the order before any reply is checked a simple request is sent and run; the check happens afterwards (steps 3–4)
the cover note "may be given to Blue House" Access-Control-Allow-Origin naming the page's origin (steps 2, 4)
writing first to ask about an unusual parcel the preflight; no yes, no parcel (step 5)
"yes, and the same for the next ten days" Access-Control-Max-Age (step 6)
a letter under the school crest needs a note naming Blue House; "any house" is refused credentials need the exact origin, never * (step 7)
a townsperson writing to the same shop directly curl or a server: no housemistress at all (step 9)

In real terms: CORS is a rule the browser applies to its own pages: whether a page may read a cross-origin response, and whether a non-simple request is sent at all.

Breaks down when: a refused reply sounds like a failed order. Wrong inference: "the page got an error, so nothing happened on the server." For a simple request the server already ran it (step 4).

Contrasting case: an outbound firewall that stops a letter from ever leaving. CORS never does that to a simple request; it only withholds the reply.

Examples, run for real

Each script starts both servers, loads the page in Chromium and prints the API's own request log, so "the server ran it" is observed, not assumed.

4.1 ExamHall: one preflight per autosave

EXH-644: every answer autosave waited for an OPTIONS round trip, because the answers API sent no Access-Control-Max-Age. MR !921 added Access-Control-Max-Age: 600. The API echoes the origin only if it is the candidate app's, and always sends Vary: Origin. The cookie in the log is the API's own host-only sid; the app's __Host-session never reaches the API's host.

fetch(`${API}/attempts/a-7/answers/q4`, { method: 'POST', credentials: 'include',
  headers: { 'Content-Type': 'application/json' }, body: '{"q":4}' });   // twice, 8 s apart, then twice 1 s apart
before MR !921 (no Max-Age), saves 8 s apart
  server: OPTIONS Origin=the app cookie=none Access-Control-Request-Method=POST -Headers=content-type
  server: POST    Origin=the app cookie=sid=cand-7 content-type=application/json
  server: OPTIONS Origin=the app cookie=none Access-Control-Request-Method=POST -Headers=content-type
  server: POST    Origin=the app cookie=sid=cand-7 content-type=application/json
  page:   page reads cors 201 saved | page reads cors 201 saved
after MR !921 (Max-Age: 600), saves 8 s apart
  server: OPTIONS Origin=the app cookie=none Access-Control-Request-Method=POST -Headers=content-type
  server: POST    Origin=the app cookie=sid=cand-7 content-type=application/json
  server: POST    Origin=the app cookie=sid=cand-7 content-type=application/json
  page:   page reads cors 201 saved | page reads cors 201 saved
no Max-Age, saves 1 s apart (inside the 5 s default)
  server: OPTIONS Origin=the app cookie=none Access-Control-Request-Method=POST -Headers=content-type
  server: POST    Origin=the app cookie=sid=cand-7 content-type=application/json
  server: POST    Origin=the app cookie=sid=cand-7 content-type=application/json
  page:   page reads cors 201 saved | page reads cors 201 saved

The JSON body makes each save non-simple, so a preflight asks first (step 5). The preflight names the method and header, and carries no cookie; the POST carries it (step 7). Without Max-Age the approval lasts the default 5 seconds, so saves 8 s apart each pay a preflight; with it, one preflight covers both (step 6).

4.2 Catalog: the wishlist that filled while the page failed

A shop page on another origin adds a lamp to a wishlist with a text/plain POST. The wishlist API sends no CORS headers at all.

Chromium page -> API: server log: POST /wishlist | count now 1 | page gets TypeError
Node fetch -> API:    status 200 | body wishlist=2 | count now 2

A simple request, so no preflight: the server added the item, and only then did the browser refuse the page the reply (steps 3–4). The second row is Node's fetch, which is not a browser: it reads the reply. CORS protected nothing on the server (step 9).

4.3 Which URLs are the page's own origin?

The page loads from http://localhost:A/. It GETs /data from three URLs; neither server sends a CORS header.

http://localhost:A/data  same scheme, host and port             reached server: yes | Origin header: none | page reads basic 200 data
http://127.0.0.1:A/data  same server and port, other host name  reached server: yes | Origin header: http://localhost:A | page gets TypeError
http://localhost:B/data  same host, other port                  reached server: yes | Origin header: http://localhost:A | page gets TypeError

The second URL reaches the very same server process, yet it is a different origin, because the host name differs (step 1). Every request arrived; only the same-origin one was readable (step 2).

Picture it

two rooms separated by a tall glass partition with a small window cut into it. On the left, a woman in green hands a book through the window; on the right, a man in blue reaches for it. Each room has a bench: the left one holds a stack of books, with a potted plant beside the partition; the right one stands behind a low table with a bottle, a cup and a book.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 5

A shop page calls its search API on another origin, and the API allows the page's origin, credentials and the X-Store header.

Which of these fetches sends an OPTIONS preflight first?

Pick one

Question 2 of 5

A chat app shows link previews from a preview API on another origin, which sends no CORS headers. After a CORS error, a developer adds mode: 'no-cors':

The API's JSON body is 27 characters long.

What does this print?

Code for this question
const r = await fetch(`${API}/preview?u=t-88`, { mode: 'no-cors' });console.log(r.type, r.status, (await r.text()).length);
Pick one

Question 3 of 5

A checkout page fetches a receipt from the payments API on another origin. The API replies 200 with Access-Control-Allow-Origin set to the page's origin, Content-Type: application/json and X-Receipt-Id: rc-19, and no other CORS header.

What does this print?

Code for this question
const r = await fetch(`${API}/receipts/r-19`);console.log(r.status, r.headers.get('content-type'), r.headers.get('x-receipt-id'));
Pick one

Question 4 of 5

A box-office page on box.example holds a seat on api.box.example (one site) with fetch(API + '/holds', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body }). The API's preflight reply echoes the page's origin from an allow-list and allows credentials, POST and content-type. The POST handler creates the hold and replies 201, echoing the same origin. Fans see "Hold failed", tap again, and the box office finds two holds per seat.

Which is the bug?

Pick one

Question 5 of 5

A video player on watch.example saves watch progress with fetch(API + '/progress/v-12', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body }). The API's preflight reply names the player's origin and sends Access-Control-Allow-Credentials: true, Access-Control-Allow-Headers: content-type and Access-Control-Allow-Methods: *. The API, on api.watch.example (one site), saves progress only for the viewer its session cookie names. No save ever arrives.

Which change makes the save arrive and the page read the reply?

Pick one

Go deeper

Pro tips
  1. Read the console, not the exception. The page's code gets a bare TypeError, but Chromium's console names the check that failed. When: any CORS failure (steps 4–7). The wording is Chromium's; other browsers word it differently.
  2. List Authorization explicitly in Access-Control-Allow-Headers; never rely on *. The Fetch Standard defines Authorization as the one "CORS non-wildcard request-header name", and MDN says it "always needs to be listed explicitly". When: bearer-token APIs called from browsers (step 6). Verify (RAN, quirk-authorization.mjs, 50 + 50 runs, 1 distinct): Chromium 153 still let a bearer request through under Access-Control-Allow-Headers: *, so a test in Chromium alone will not catch the omission. This is a Chromium divergence from the standard, not the rule, and no item grades it.
Source
  • MDN, Cross-Origin Resource Sharing (CORS): https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS · fetched 2026-09-27. Simple-request conditions (methods, safelisted headers, the three content types); preflight "to determine if the actual request is safe to send"; "CORS-preflight requests must never include credentials" (#preflight_requests_and_credentials); a credentialed response must not use * in Allow-Origin, Allow-Headers, Allow-Methods or Expose-Headers (#credentialed_requests_and_wildcards). Supports steps 3 and 5–8. Valid as of 2026-09-27.
  • MDN, Access-Control-Max-Age: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age · fetched 2026-09-27: default 5 seconds; Chromium (v76 on) caps at 7200 s, Firefox at 86400 s. Valid as of 2026-09-27.
  • MDN, Access-Control-Allow-Headers: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Headers · fetched 2026-09-27: * is a wildcard only without credentials, a literal name with them; Authorization "always needs to be listed explicitly". Supports step 7 and pro tip 2. Valid as of 2026-09-27.
  • MDN, Same-origin policy: https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Same-origin_policy · fetched 2026-09-27: same origin means the same protocol, port and host; "Cross-origin writes are typically allowed", while cross-origin reads "are typically disallowed". Supports steps 1, 2 and 4. Valid as of 2026-09-27.
  • Cross-pack: SameSite, Secure, HttpOnly, Partitioned and CSRF belong to packs/net-cookies-samesite.md; every run here keeps page and API on one site (localhost, two ports), so no SameSite rule is exercised. The production-only CORS incident (allow-list mismatch) belongs to incident-cors-prod-only.

Checked against the tools and docs as of 2026-09-27.