HTTP & APIs in Practice
HTTP & APIs in Practice

CORS & preflight: the browser's rule, the server's opt-in

Full · ~35 min
After this unit you can
  • Explain the same-origin policy and what CORS relaxes: the request may still run; CORS decides whether the page can read the response
  • Predict whether a fetch call is preflighted from its method, headers and Content-Type (the Fetch standard's safelists)
  • Write the response headers for a credentialed cross-origin API: the exact origin, Allow-Credentials: true, explicit header names, Vary: Origin
  • Explain why curl and server-to-server calls never see CORS, and why CORS is not server-side access control

Read

Simple
1 / 2

CORS is the server relaxing a browser rule

Flag

An origin is scheme + host + port. Compared with https://app.example.com:

URLSame origin?
https://app.example.com/orders?x=1yes — path and query don't count
http://app.example.comno — scheme
https://api.example.comno — host (a subdomain is a different host)
https://app.example.com:8443no — port

The same-origin policy is a browser rule: a script running on one origin may send many kinds of requests to another origin, but it may not read the response unless that other origin says it may. CORS (Cross-Origin Resource Sharing, defined in the Fetch standard) is how the other origin says so — with response headers.

Here is that rule in a real browser. A page on http://localhost:4124 fetches http://localhost:4123/no-cors, an endpoint that sends no CORS headers:

server log:      GET /no-cors  origin=http://localhost:4124       <- the request arrived and ran
page's script:   TypeError: Failed to fetch                       <- the response was withheld
$ curl http://localhost:4123/no-cors
{"secret":"visible to curl, hidden from other origins' scripts"}

Three facts in that one experiment:

  1. The request reached the server and the handler ran. CORS did not stop it.
  2. The browser blocked the page from reading the response, because nothing in it said http://localhost:4124 was allowed. To the script it's an opaque network error; MDN notes that CORS failure details "are not available to JavaScript" — the reason appears only in the browser console.
  3. curl got the data. CORS is enforced by browsers, for scripts running in web pages. curl, a Node server, a mobile app's HTTP client — none of them implement it, so none of them ever see a CORS error.

The fix for the page is on the server: send Access-Control-Allow-Origin: http://localhost:4124 (or * for a public, non-credentialed resource) and the browser hands the response to the script. Nothing you write in the page's fetch call can grant itself that permission.

Medium

Writing the server side — an allow-list, credentials, and Vary

Flag

An API at https://api.example.com serves a web app at https://app.example.com (and an admin app on another origin). The app sends cookies, so requests are credentialed. Here's the whole CORS layer in plain Node:

const ALLOWED_ORIGINS = new Set(["https://app.example.com", "https://admin.example.com"]);

/** Returns true when it answered the request itself (a preflight). */
function handleCors(req: IncomingMessage, res: ServerResponse): boolean {
  const origin = req.headers.origin;
  const allowedOrigin = origin && ALLOWED_ORIGINS.has(origin) ? origin : null; // exact match only

  res.setHeader("Vary", "Origin");                   // the answer depends on Origin: tell caches
  if (allowedOrigin) {
    res.setHeader("Access-Control-Allow-Origin", allowedOrigin);   // the one origin, never "*"
    res.setHeader("Access-Control-Allow-Credentials", "true");
  }

  const isPreflight = req.method === "OPTIONS" && req.headers["access-control-request-method"] !== undefined;
  if (!isPreflight) return false;                    // a real request: let the route run

  if (!allowedOrigin) {
    res.writeHead(403).end();                        // no grant headers: the browser stops here
    return true;
  }
  res.writeHead(204, {
    "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE",
    "Access-Control-Allow-Headers": "authorization, content-type",
    "Access-Control-Max-Age": "600",
  }).end();
  return true;
}

Probed with curl (Node 24): a preflight from https://app.example.com gets 204 with all the grant headers; one from https://app.example.com.evil.test gets 403 with only Vary: Origin; a request with no Origin at all gets the data and no CORS headers.

The credential rules

From the Fetch standard's table of legal and illegal combinations:

Request sends credentials?Access-Control-Allow-OriginAccess-Control-Allow-CredentialsShared?
no*—yes
nohttps://app.example.com/—no — an origin has no trailing slash
yes*trueno — with credentials it can't be *
yeshttps://app.example.comtrueyes
yeshttps://app.example.comTrueno — true is case-sensitive

And with credentials, * in Access-Control-Allow-Methods, -Allow-Headers or -Expose-Headers is not a wildcard either — it means a method or header literally named *. Tested in Chrome: a credentialed request to an endpoint answering Allow-Headers: * failed its preflight; the same request without credentials passed.

Why each line is there

  • Exact match from a list. Access-Control-Allow-Origin holds exactly one origin or *; you can't list several. To serve several, compare the request's Origin against your list and echo the match.
  • Vary: Origin on every response, allowed or not. The response now differs by Origin, and the Fetch standard spells out the failure without it: a cache stores a response that has no Access-Control-Allow-Origin (say, from a navigation) and later serves it to a CORS request, which then fails.
  • No grant on a refused preflight. A 403 without CORS headers is how the standard suggests saying no explicitly.

Complex

CORS is not a firewall — what it protects, and the allow-lists that break it

Flag

CORS protects the user's browser from other sites reading responses as that user. It does not protect your server from requests. Three consequences that catch people out:

1. Simple requests run before anyone checks

In the browser test from the first example, the request to an endpoint with no CORS headers reached the server and its handler ran; the browser only withheld the answer from the page. A simple POST behaves the same way — a text/plain or form body needs no preflight — so a cross-site form or fetch can trigger a state-changing POST on your API whatever your CORS headers say. Stopping that is CSRF protection (Web Security track), not CORS.

2. Origin is just a header

$ curl -i -H 'Origin: https://app.example.com' https://api.example.com/cart

Anything that isn't a browser can send any Origin it likes. A browser sets it honestly for the page's own origin — which is the only reason it's useful — so "only answer requests from our origin" is never authentication. Every request still needs real credentials checked on the server.

3. A sloppy allow-list hands your users' data to any site

With credentials allowed, every origin on the list can read responses made with the user's cookies. These all look like allow-lists and aren't:

CheckLets in
origin.startsWith("https://app.example.com")https://app.example.com.evil.test
origin.endsWith("example.com")https://evilexample.com
/https:\/\/.*\.example\.com/.test(origin) (no anchors)https://app.example.com.evil.test
/^https:\/\/.*.example.com$/.test(origin) (unescaped dots)https://evilexample.com — an unescaped . matches any character
reflect whatever Origin arrivesevery site on the internet
"null" on the listany page that can make its origin opaque — a sandboxed iframe, a data: document

The safe shape is boring: a Set of exact, full origins (scheme, host and port, no trailing slash), checked with Set.has — exact string equality. If you truly need every subdomain, parse the origin with new URL(origin) and compare url.protocol and url.hostname (=== "example.com" or .endsWith(".example.com") — with the leading dot) rather than matching the raw string.

When it's not your API

You can't fix CORS from the browser side. If a third-party API doesn't allow your origin, call it from your own server — a route handler or backend-for-frontend endpoint on your origin — where CORS doesn't apply, and keep any API key there instead of in the page.

Quiz

Which URL is the same origin as https://app.example.com?

Flag

curl https://api.partner.test/rates returns JSON. The same URL from your web app's fetch fails with a CORS error. Where is the fix?

Flag

A page calls fetch("https://api.other.test/track?event=click") — a plain GET. The endpoint sends no CORS headers, and the script gets TypeError: Failed to fetch. Did the endpoint's handler run?

Flag

Which cross-origin call goes out without a preflight?

Flag

The API answers 200 with Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true. The app calls it with credentials: "include". What does the app's script get?

Flag

Two front-ends, https://app.example.com and https://admin.example.com, call the same API with cookies. How does the API allow both?

Flag

"The admin API is safe: its CORS allow-list only contains https://admin.example.com." What's wrong with that?

Flag

Fill in the blank

The browser asks permission before a PUT with JSON and a bearer token.

Flag
OPTIONS /cart HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-: PUT
Access-Control-Request-: authorization,content-type

Grant the app's credentialed request, and keep caches honest.

Flag
Access-Control-Allow-Origin: 
Access-Control-Allow-Credentials: 
Vary: 

Make a cross-origin fetch send the user's cookies.

Flag
await fetch("https://api.example.com/cart", { : "include" });

Let browsers reuse this preflight's answer for ten minutes instead of the default five seconds.

Flag
Access-Control-: 600

Predict the output

// Page on http://localhost:4124, API on http://localhost:4123
await fetch("http://localhost:4123/api/cart", {
  method: "POST",
  body: JSON.stringify({ sku: "TEA-1" }),
});
Flag
// Page on http://localhost:4124, API on http://localhost:4123
// The API answers every request with:
//   200 OK
//   Access-Control-Allow-Origin: *
const res = await fetch("http://localhost:4123/api-wild/9", { credentials: "include" });
console.log(res.status);
Flag
// Preflight answer: 204, Allow-Origin: http://localhost:4124,
//   Allow-Methods: GET, POST, PUT, DELETE
await fetch("http://localhost:4123/api/cart", { method: "PATCH", body: "{}" });
Flag

Challenge

Fix the API's CORS allow-list

Flag

cors() runs before every route of api.example.com. The web app (https://app.example.com) and the admin app (https://admin.example.com) call it with fetch(…, { credentials: "include" }) and an Authorization header. Since the last refactor, the app's requests fail in the browser with CORS errors. A security review then found something worse waiting behind that bug: the allow-list check also lets in app.example.com.evil.test — a domain anyone can register — so the moment someone "fixes" the CORS errors by echoing the caller's origin, that site can read logged-in users' data.

What the browser needs (Fetch standard, CORS protocol):

  • access-control-allow-origin holds one origin. For a credentialed request it must be the caller's exact origin — * is refused — plus access-control-allow-credentials: true.
  • An origin is scheme + host + port. https://app.example.com:8443 and http://app.example.com are different origins from https://app.example.com.
  • The response now depends on the Origin request header, so caches must be told: vary: Origin on every response this function shapes.
  • A preflight (OPTIONS with access-control-request-method) from an allowed origin gets 204 and the grants: methods, header names and max age from the config. With credentials, * in access-control-allow-headers is just a header literally named *.
  • A preflight from anyone else gets 403 and no grants.

cors-types.ts has the types. All header names are lowercase.

Your task

Edit cors.ts so the tests pass:

  • An allowed origin is echoed exactly in access-control-allow-origin (never *), with access-control-allow-credentials: true when the config allows credentials
  • Lookalike origins — a longer host, another port, another scheme — get no access-control-allow-origin
  • Every response says vary: Origin, including refused ones and ones without an Origin header
  • An allowed preflight gets 204 with the configured methods, the configured header names (not *) and the max age
  • A preflight from an unlisted origin gets 403 and no grant headers
  • A request without Origin, or an OPTIONS without access-control-request-method, is passed to the route; the former gets no access-control-* headers

Loading the in-browser editor…

Further reading