CORS & preflight: the browser's rule, the server's opt-in
- 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
fetchcall is preflighted from its method, headers andContent-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
CORS is the server relaxing a browser rule
FlagAn origin is scheme + host + port. Compared with https://app.example.com:
| URL | Same origin? |
|---|---|
https://app.example.com/orders?x=1 | yes — path and query don't count |
http://app.example.com | no — scheme |
https://api.example.com | no — host (a subdomain is a different host) |
https://app.example.com:8443 | no — 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:
- The request reached the server and the handler ran. CORS did not stop it.
- The browser blocked the page from reading the response, because nothing in it said
http://localhost:4124was 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. - 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.
Writing the server side — an allow-list, credentials, and Vary
FlagAn 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-Origin | Access-Control-Allow-Credentials | Shared? |
|---|---|---|---|
| no | * | — | yes |
| no | https://app.example.com/ | — | no — an origin has no trailing slash |
| yes | * | true | no — with credentials it can't be * |
| yes | https://app.example.com | true | yes |
| yes | https://app.example.com | True | no — 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-Originholds exactly one origin or*; you can't list several. To serve several, compare the request'sOriginagainst your list and echo the match. Vary: Originon every response, allowed or not. The response now differs byOrigin, and the Fetch standard spells out the failure without it: a cache stores a response that has noAccess-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
403without CORS headers is how the standard suggests saying no explicitly.
CORS is not a firewall — what it protects, and the allow-lists that break it
FlagCORS 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:
| Check | Lets 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 arrives | every site on the internet |
"null" on the list | any 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?
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?
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?
Which cross-origin call goes out without a preflight?
FlagThe 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?
Two front-ends, https://app.example.com and https://admin.example.com, call the same API with cookies. How does the API allow both?
"The admin API is safe: its CORS allow-list only contains https://admin.example.com." What's wrong with that?
Fill in the blank
The browser asks permission before a PUT with JSON and a bearer token.
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.
FlagAccess-Control-Allow-Origin: Access-Control-Allow-Credentials: Vary:
Make a cross-origin fetch send the user's cookies.
await fetch("https://api.example.com/cart", { : "include" });
Let browsers reuse this preflight's answer for ten minutes instead of the default five seconds.
FlagAccess-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" }),
});// 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);// Preflight answer: 204, Allow-Origin: http://localhost:4124,
// Allow-Methods: GET, POST, PUT, DELETE
await fetch("http://localhost:4123/api/cart", { method: "PATCH", body: "{}" });Challenge
Fix the API's CORS allow-list
Flagcors() 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-originholds one origin. For a credentialed request it must be the caller's exact origin —*is refused — plusaccess-control-allow-credentials: true.- An origin is scheme + host + port.
https://app.example.com:8443andhttp://app.example.comare different origins fromhttps://app.example.com. - The response now depends on the
Originrequest header, so caches must be told:vary: Originon every response this function shapes. - A preflight (
OPTIONSwithaccess-control-request-method) from an allowed origin gets204and the grants: methods, header names and max age from the config. With credentials,*inaccess-control-allow-headersis just a header literally named*. - A preflight from anyone else gets
403and 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*), withaccess-control-allow-credentials: truewhen 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 anOriginheader - An allowed preflight gets
204with the configured methods, the configured header names (not*) and the max age - A preflight from an unlisted origin gets
403and no grant headers - A request without
Origin, or anOPTIONSwithoutaccess-control-request-method, is passed to the route; the former gets noaccess-control-*headers