Web Security (OWASP Top 10:2025) Module 2 – A01:2025 Broken Access Control
CORS: what it protects and how it goes wrong
What CORS relaxes and what it never protected: simple and preflighted requests, allowlist mistakes that open an API to every site, and an exact setup.
What you will learn
- Explain what CORS relaxes, the block on reading another origin's response, and what it does not protect
- Trace a simple request and a preflighted request and name the headers each side must send
- Avoid a reflected Origin, the null origin, a wildcard with credentials and prefix or suffix matching
- Configure an exact-origin allowlist with Vary Origin and the fewest methods and headers, or choose a design that needs no CORS
Before you start
What you’ll learn · Before you start
What you will learn
- Explain what CORS relaxes, the block on reading another origin's response, and what it does not protect
- Trace a simple request and a preflighted request and name the headers each side must send
- Avoid a reflected Origin, the null origin, a wildcard with credentials and prefix or suffix matching
- Configure an exact-origin allowlist with Vary Origin and the fewest methods and headers, or choose a design that needs no CORS
Before you start
On this page
CORS lets a browser page read an API’s answer, and one careless string comparison lets every site do it. Of the ten ways the second program below tries to decide that an origin is yours, nine accept at least one origin that is not.
You will see what CORS relaxes and what it never did, follow a simple and a preflighted request header by header, learn why lookalike origins pass most matchers, and configure an exact allowlist, or choose a design that needs no CORS at all.
CORS lets a page read a response; it never decided who may send
The same-origin policy: The browser rule that a page's script may read responses and documents only from its own origin, unless the other origin allows it with CORS. It limits reading; requests to other origins are still sent. More in “HTTP, origins and the browser security model” stops a page’s script from reading what another origin sends back. cross-origin resource sharing (CORS): Response headers by which a server tells browsers which other origins may read its answers. It relaxes the same-origin policy's block on reading. It does not stop requests from being sent and does not identify the caller. is how a server relaxes that rule for the origins it names: headers on its answer tell the browser that a given page may read it. Four facts follow, and each is easy to forget.
- It is a rule the browser applies for its own page.
curl, another server and a mobile app ignore it, so it protects nobody from them. - It decides who may read an answer, not who may send a request. A simple request reaches your server whatever your headers say, as the last lesson showed. MDN’s reasoning is that forms could always send such requests, so servers already guard against them.
- Every origin you allow is a party you trust. When the request carries the user’s cookie, a page on an allowed origin can call your API as that user and read the answers.
- It is not access control. OWASP’s HTML5 cheat sheet says not to rely only on the
Originheader for access control checks, and the session and permission checks stay where they were.
The program is a model of the CORS rules in the Fetch standard for the cases it shows, and it sends nothing over a
network. A page on https://app.example calls an API on https://api.example. For each answer the API could give, it
prints whether the browser sent the request and whether the script may read the response. Read the first group now;
the other two return later in the lesson:
// What a browser lets a page's script do with an API's CORS answers. The page is https://app.example and the API is
// https://api.example. This is a model of the CORS rules in the Fetch standard for the cases shown: it is not a
// browser, and the standard has more cases than these.
const PAGE = 'https://app.example';
const FORM_TYPES = ['application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain'];
const SAFE_HEADERS = ['accept', 'accept-language', 'content-language'];
// A request needs a preflight unless its method is GET, HEAD or POST and its headers are all safelisted.
function needsPreflight(req) {
if (!['GET', 'HEAD', 'POST'].includes(req.method)) return true;
return Object.entries(req.headers).some(([name, value]) => (name === 'content-type' ? !FORM_TYPES.includes(value) : !SAFE_HEADERS.includes(name)));
}
// The check on Access-Control-Allow-Origin and -Credentials: why the script may not read the answer, or null.
function readCheck(h, credentials) {
const origin = h['access-control-allow-origin'];
if (origin === undefined) return 'no Allow-Origin header';
if (origin === '*') return credentials ? 'a wildcard cannot go with credentials' : null;
if (origin !== PAGE) return `Allow-Origin is ${origin}`;
if (credentials && h['access-control-allow-credentials'] !== 'true') return 'Allow-Credentials is not exactly true';
return null;
}
// The preflight answer must pass the same check, and list the method (compared exactly) and every non-safelisted
// header (compared without regard to case). A * counts as a wildcard only when the request sends no credentials.
function preflightCheck(req, h) {
const why = readCheck(h, req.credentials);
if (why) return `preflight: ${why}`;
const items = (name, lower) => (h[name] ?? '').split(',').map((s) => (lower ? s.trim().toLowerCase() : s.trim()));
const methods = items('access-control-allow-methods', false);
const headers = items('access-control-allow-headers', true);
const wildcardOk = !req.credentials;
if (!(methods.includes(req.method) || (wildcardOk && methods.includes('*')))) return `preflight: Allow-Methods lacks ${req.method}`;
for (const name of Object.keys(req.headers)) {
if (SAFE_HEADERS.includes(name)) continue;
if (!(headers.includes(name) || (wildcardOk && headers.includes('*')))) return `preflight: Allow-Headers lacks ${name}`;
}
return null;
}
function run(req, preflight, response) {
const pre = needsPreflight(req) ? preflightCheck(req, preflight ?? {}) : null;
if (pre) return { sent: 'no', reads: 'no', why: pre };
const why = readCheck(response, req.credentials);
return { sent: 'yes', reads: why ? 'no' : 'yes', why: why ?? '' };
}
const getMe = { method: 'GET', headers: {}, credentials: true };
const getCatalogue = { method: 'GET', headers: {}, credentials: false };
const putMe = { method: 'PUT', headers: { 'content-type': 'application/json' }, credentials: true };
const exact = { 'access-control-allow-origin': PAGE, 'access-control-allow-credentials': 'true' };
const rows = [
['GET /me with the cookie', null],
[' no CORS headers', run(getMe, null, {})],
[' Allow-Origin: *', run(getMe, null, { 'access-control-allow-origin': '*' })],
[' Allow-Origin: the page, nothing else', run(getMe, null, { 'access-control-allow-origin': PAGE })],
[' Allow-Origin: the page, Credentials: true', run(getMe, null, exact)],
[' ... with Credentials: True', run(getMe, null, { ...exact, 'access-control-allow-credentials': 'True' })],
[' Allow-Origin: the page with a slash', run(getMe, null, { ...exact, 'access-control-allow-origin': `${PAGE}/` })],
[' Allow-Origin: the page, then another', run(getMe, null, { ...exact, 'access-control-allow-origin': `${PAGE}, https://admin.example` })],
[' Allow-Origin: null', run(getMe, null, { ...exact, 'access-control-allow-origin': 'null' })],
['GET /catalogue, no cookie', null],
[' Allow-Origin: *', run(getCatalogue, null, { 'access-control-allow-origin': '*' })],
['PUT /me with JSON and the cookie', null],
[' no answer to the preflight', run(putMe, {}, exact)],
[' preflight lists POST only', run(putMe, { ...exact, 'access-control-allow-methods': 'POST' }, exact)],
[' preflight lists PUT, not Content-Type', run(putMe, { ...exact, 'access-control-allow-methods': 'PUT' }, exact)],
[' preflight lists PUT and Content-Type', run(putMe, { ...exact, 'access-control-allow-methods': 'PUT', 'access-control-allow-headers': 'content-type' }, exact)],
[' Allow-Methods: * with the cookie', run(putMe, { ...exact, 'access-control-allow-methods': '*', 'access-control-allow-headers': 'content-type' }, exact)],
];
console.log(`${'the API answers'.padEnd(46)}${'sent'.padEnd(6)}${'script reads'.padEnd(14)}why not`);
for (const [label, r] of rows) {
console.log(r ? `${label.padEnd(46)}${r.sent.padEnd(6)}${r.reads.padEnd(14)}${r.why}`.trimEnd() : label);
} Output
the API answers sent script reads why not GET /me with the cookie no CORS headers yes no no Allow-Origin header Allow-Origin: * yes no a wildcard cannot go with credentials Allow-Origin: the page, nothing else yes no Allow-Credentials is not exactly true Allow-Origin: the page, Credentials: true yes yes ... with Credentials: True yes no Allow-Credentials is not exactly true Allow-Origin: the page with a slash yes no Allow-Origin is https://app.example/ Allow-Origin: the page, then another yes no Allow-Origin is https://app.example, https://admin.example Allow-Origin: null yes no Allow-Origin is null GET /catalogue, no cookie Allow-Origin: * yes yes PUT /me with JSON and the cookie no answer to the preflight no no preflight: no Allow-Origin header preflight lists POST only no no preflight: Allow-Methods lacks PUT preflight lists PUT, not Content-Type no no preflight: Allow-Headers lacks content-type preflight lists PUT and Content-Type yes yes Allow-Methods: * with the cookie no no preflight: Allow-Methods lacks PUT
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node browser_verdicts.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
In every row of the first group the request was sent, and in seven of the eight the script may not read the answer. The
API ran its code each time. Only one answer opens the response to the page, the exact origin together with
Access-Control-Allow-Credentials: true. Remove the credentials header, or change one character of the origin, and
the script gets an error while whatever the request did on the server stays done.
Reading a CORS error as a request that failed
The console says a request was blocked by CORS policy. For a simple request that means the response was withheld from the script, not that nothing ran. Look in the server’s log before you conclude that the API did nothing.
Checkpoint
Check: What CORS relaxes and what it does not
A simple request goes out at once; a preflight asks first
The last lesson defined the simple request: A cross-origin request that a browser sends without asking first: GET, HEAD or POST with only safelisted headers and, for a POST, a form content type. The page cannot read the answer, but the server has already acted. More in “CSRF: tokens, SameSite and Fetch Metadata”: a GET, HEAD or POST with only safelisted headers and, for a POST, a form content type. A browser sends that at once. Anything else, such as a PUT, a JSON body or a custom header, starts with a preflight: The OPTIONS request a browser sends before a cross-origin request that is not simple. It asks which methods and headers the server allows, and if the answer gives no permission the real request is never sent. More in “CSRF: tokens, SameSite and Fetch Metadata”, an OPTIONS request that the browser sends without cookies. The real request follows only if the answer to the preflight gives the permission it asked for. These are the headers each side must send:
| Header | Sent by | What it says |
|---|---|---|
Origin |
The browser, on the preflight and on the real request | The origin of the page that makes the call |
Access-Control-Request-Method |
The browser, on the preflight | The method the real request will use |
Access-Control-Request-Headers |
The browser, on the preflight | The names of the non-safelisted headers it will send |
Access-Control-Allow-Origin |
The server, on both answers | One origin, exactly, or * for a request without credentials |
Access-Control-Allow-Methods |
The server, on the preflight answer | The methods allowed besides GET, HEAD and POST |
Access-Control-Allow-Headers |
The server, on the preflight answer | The request headers allowed besides the safelisted ones |
Access-Control-Allow-Credentials |
The server, when cookies are involved | The text true, written exactly like that |
Access-Control-Max-Age |
The server, on the preflight answer | Seconds that the browser may reuse the answer, 5 by default |
Step through one PUT with a JSON body and the user’s cookie, and see where each check can stop it:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The script calls the API. A script on https://app.example calls fetch() on https://api.example/me with the method PUT, a JSON body and credentials set to include. A PUT with that content type is not a simple request, so the browser must ask first.
- The browser sends a preflight. It sends an OPTIONS request without the cookie. It carries Origin, the method it wants in Access-Control-Request-Method and the header names in Access-Control-Request-Headers. This request changes nothing on the server.
- The API answers the preflight. The answer must name the page's origin exactly and say Access-Control-Allow-Credentials true, because the real request will carry a cookie. It must also list PUT and content-type, and it may set Access-Control-Max-Age, which defaults to 5 seconds.
- The browser compares. Every check is a plain comparison. A missing header, a wildcard with credentials, another origin or a method that is not listed ends here. The real request is then never sent, and the script gets a network error with no detail.
- The real request goes out. Only now is the PUT sent, with the cookie and the Origin header. From here the API can act on it like on any request, so its own session and CSRF checks still apply.
- The answer is read if it passes. The response needs Access-Control-Allow-Origin and Access-Control-Allow-Credentials as well. Without them the PUT has still happened, but the script sees an error and cannot read the answer.
The last group of rows in the program traces the same checks. With no answer to the preflight, with an answer that
lists POST only, and with one that lists PUT but not content-type, the request is never sent: the first column says
no. Only the answer that lists both lets it out. The final row shows a trap. Access-Control-Allow-Methods: *
reads as a wildcard only for requests without credentials, so with a cookie the browser looks for a method called *
and finds no PUT. A browser also caps how long it keeps a preflight answer, 2 hours in Chromium and 24 hours in
Firefox, according to MDN.
Asking for a session on OPTIONS
The preflight carries no cookie, so a route that demands a session for every method, OPTIONS included, refuses every preflight and every non-simple call fails. Answer OPTIONS from the CORS layer before authentication, and let it send only the headers above.
Checkpoint
Check: Simple requests and preflights
An allowlist is exact, or it is a door
Most servers that answer more than one origin read the request’s Origin, decide whether it is theirs and, if so,
send it back in Access-Control-Allow-Origin. The decision is where the weakness lives. A browser sets Origin from
the page’s real origin, and anyone can register a host name that looks like yours, so the origin of a lookalike page
arrives as a perfectly honest header. The second program tries ten ways to decide that an origin is yours on eight
origins, one real and seven that are not, or not meant. All the names end in .example or .test, which are reserved
for examples:
# Ten ways to decide that an Origin header is "ours", tried on eight origins. The only origin we mean is
# https://shop.example. Every host name here ends in .example or .test, which are reserved for examples.
import re
from urllib.parse import urlsplit
REAL = "https://shop.example"
origins = [
("https://shop.example", "the real origin"),
("https://shop.example.other.test", "starts with the real origin"),
("https://eshop.example", "ends with shop.example"),
("http://shop.example", "the same host over http"),
("https://shop.example:8443", "the same host on another port"),
("https://shop-example", "a hyphen where the dot is"),
("https://blog.shop.example", "a subdomain"),
("null", "a sandboxed frame or a file page"),
]
def host(origin):
return urlsplit(origin).hostname or ""
matchers = [
("reflect any Origin", lambda o: True),
("exact string, and null too", lambda o: o in (REAL, "null")),
("o.endswith('shop.example')", lambda o: o.endswith("shop.example")),
("o.startswith(REAL)", lambda o: o.startswith(REAL)),
("'shop.example' in o", lambda o: "shop.example" in o),
("regex, dot not escaped", lambda o: re.fullmatch(r"https://shop.example", o) is not None),
("regex, no end anchor", lambda o: re.match(r"https://shop\.example", o) is not None),
("the host or any subdomain", lambda o: host(o) == "shop.example" or host(o).endswith(".shop.example")),
("the host only, scheme ignored", lambda o: host(o) == "shop.example"),
("exact string in a set", lambda o: o in {REAL}),
]
for number, (origin, note) in enumerate(origins, 1):
print(f"{number} {origin:<33}{note}")
print()
print(f"{'accepted by':<31}" + " ".join(str(n) for n in range(1, len(origins) + 1)))
for name, accepts in matchers:
print(f"{name:<31}" + " ".join("Y" if accepts(origin) else "." for origin, _ in origins)) Output
1 https://shop.example the real origin
2 https://shop.example.other.test starts with the real origin
3 https://eshop.example ends with shop.example
4 http://shop.example the same host over http
5 https://shop.example:8443 the same host on another port
6 https://shop-example a hyphen where the dot is
7 https://blog.shop.example a subdomain
8 null a sandboxed frame or a file page
accepted by 1 2 3 4 5 6 7 8
reflect any Origin Y Y Y Y Y Y Y Y
exact string, and null too Y . . . . . . Y
o.endswith('shop.example') Y . Y Y . . Y .
o.startswith(REAL) Y Y . . Y . . .
'shop.example' in o Y Y Y Y Y . Y .
regex, dot not escaped Y . . . . Y . .
regex, no end anchor Y Y . . Y . . .
the host or any subdomain Y . . Y Y . Y .
the host only, scheme ignored Y . . Y Y . . .
exact string in a set Y . . . . . . .
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 origin_match.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Test your own matcher
Press Edit and add a line to matchers that does what your API does today, then run it. Any Y outside column 1 is an
origin you would have to explain.
Read down column 1 and then across a row. The real origin passes every matcher. Reflecting any Origin accepts all
eight, and ASVS 5.0.0 requirement 3.4.2 says an echoed origin must be validated against an allowlist of trusted origins.
The second row
is an exact list that also lists null, which the next section explains. The rows after it fail in different ways:
endswith takes https://eshop.example and plain http, startswith takes a longer host and another port, in
takes six of the eight, and a regular expression with an unescaped dot takes https://shop-example because the dot
stands for any character. Without an end anchor, a regular expression accepts the longer host and the other port.
Two rows are choices as much as mistakes. Allowing the host and every subdomain is exactly as safe as the weakest subdomain you own, and a subdomain can be forgotten, handed to a hosting service or taken over. Comparing only the host ignores the scheme and the port, which are part of an origin. Only the last matcher, an exact string in a set of whole origins, accepts the real origin and nothing else.
Key idea
Compare the whole Origin string with a list of whole origins: every other test, a prefix, a suffix, a substring, a
pattern or a host name alone, accepts something you did not mean.
Checkpoint
Check: Exact allowlists and lookalike origins
Credentials, null and caches change what a header means
A request that carries the user’s cookie, credentials: 'include' in fetch(), is a
credentialed request: A cross-origin request that carries the user's cookies or other credentials, such as fetch() with credentials set to include. The server must name one exact origin and send Allow-Credentials true, because browsers refuse a wildcard answer., and the rules tighten. Access-Control-Allow-Origin: *
no longer works, and neither does * in Access-Control-Allow-Methods, Access-Control-Allow-Headers or
Access-Control-Expose-Headers. The answer must name one origin, and Access-Control-Allow-Credentials must be exactly
true, which is why the wildcard row and the row with a capital T in the first group of the first program both fail.
That requirement is also why reflecting Origin is tempting: it makes the error go away. It does so by trusting every
site.
Whether the cookie goes at all is a separate question. MDN notes that the browser’s third-party cookie policy applies
whatever the server and the page configure, and SameSite still decides which requests carry the cookie, as the CSRF
lesson showed. When the page and the API are the same site, a Lax cookie goes along; when they are not, the cookie
needs SameSite=None.
Browsers send the origin null for some pages, among them sandboxed frames and files opened from disk, and a site’s own
form post can carry it under Referrer-Policy: no-referrer. The
null origin: The value null in an Origin header. Browsers send it for sandboxed frames, local files and some redirects, and a site's own form post can carry it under Referrer-Policy no-referrer. It names no site, so it is never a match. identifies no site, and MDN warns that any site can make a document with a null
origin, so an Access-Control-Allow-Origin: null lets every site in. The third program puts a weak middleware, the
kind that grows out of fixing errors one at a time, beside a fixed one. Pages on six origins ask for GET /me with
their cookie, a page on the real origin sends a PUT with a header nobody planned for, and then a shared cache sits in
front of the API:
// The same API behind a weak CORS middleware and a fixed one. Pages on six origins ask for GET /me with their cookie,
// and a page on the real origin sends a PUT that needs a preflight. The program prints what each server answers and
// whether the page could read it. At the end a shared cache sits in front of the API and two pages ask in turn.
const REAL = 'https://shop.example';
function weakCors(req) {
const origin = req.headers.origin;
const h = {};
if (origin && (origin.endsWith('shop.example') || origin === 'null')) {
h['access-control-allow-origin'] = origin; // echoes whatever matched
h['access-control-allow-credentials'] = 'true';
}
if (req.method === 'OPTIONS') {
h['access-control-allow-methods'] = 'GET, POST, PUT, PATCH, DELETE';
h['access-control-allow-headers'] = req.headers['access-control-request-headers'] ?? ''; // echoes what was asked
}
return h;
}
const ALLOWED = new Set([REAL, 'https://admin.shop.example']);
function fixedCors(req) {
const origin = req.headers.origin;
const h = { vary: 'Origin' };
if (origin !== undefined && ALLOWED.has(origin)) {
h['access-control-allow-origin'] = origin;
h['access-control-allow-credentials'] = 'true';
if (req.method === 'OPTIONS') {
h['access-control-allow-methods'] = 'GET, POST';
h['access-control-allow-headers'] = 'content-type';
h['access-control-max-age'] = '600';
}
}
return h;
}
const readable = (page, h) => h['access-control-allow-origin'] === page && h['access-control-allow-credentials'] === 'true';
const show = (h) => h['access-control-allow-origin'] ?? '(none)';
const pages = [REAL, 'https://admin.shop.example', 'https://eshop.example', 'http://shop.example', 'null', 'https://other.example'];
console.log(`${'GET /me with the cookie, from'.padEnd(32)}${'weak: Allow-Origin sent, page reads?'.padEnd(46)}fixed`);
for (const page of pages) {
const req = { method: 'GET', headers: { origin: page } };
const w = weakCors(req);
const f = fixedCors(req);
console.log(`${page.padEnd(32)}${`${show(w)}, ${readable(page, w) ? 'yes' : 'no'}`.padEnd(46)}${show(f)}, ${readable(page, f) ? 'yes' : 'no'}`);
}
// A PUT with a custom header from the real origin: the preflight lists what the browser may then send.
const ask = { method: 'OPTIONS', headers: { origin: REAL, 'access-control-request-method': 'PUT', 'access-control-request-headers': 'content-type, x-debug-admin' } };
const lists = (h) => `methods: ${h['access-control-allow-methods'] ?? '(none)'}; headers: ${h['access-control-allow-headers'] ?? '(none)'}`;
console.log(`\npreflight for PUT with x-debug-admin, from ${REAL}`);
console.log(` weak ${lists(weakCors(ask))}`);
console.log(` fixed ${lists(fixedCors(ask))}`);
// A shared cache keyed by URL, plus the headers that the stored response lists in Vary.
function cachedAnswers(server) {
const store = new Map();
return ['https://shop.example', 'https://admin.shop.example'].map((page) => {
const req = { method: 'GET', headers: { origin: page } };
const h = server(req);
const vary = (h.vary ?? '').toLowerCase().split(',').map((s) => s.trim());
const key = `/me${vary.includes('origin') ? ` ${page}` : ''}`;
if (!store.has(key)) store.set(key, h);
return { page, got: store.get(key) };
});
}
console.log('\nshared cache in front of the API, two pages ask for /me in turn');
for (const [name, server] of [['weak', weakCors], ['fixed', fixedCors]]) {
const [, second] = cachedAnswers(server);
console.log(` ${name.padEnd(6)}second page reads the cached answer? ${readable(second.page, second.got) ? 'yes' : `no, it got Allow-Origin ${show(second.got)}`}`);
} Output
GET /me with the cookie, from weak: Allow-Origin sent, page reads? fixed https://shop.example https://shop.example, yes https://shop.example, yes https://admin.shop.example https://admin.shop.example, yes https://admin.shop.example, yes https://eshop.example https://eshop.example, yes (none), no http://shop.example http://shop.example, yes (none), no null null, yes (none), no https://other.example (none), no (none), no preflight for PUT with x-debug-admin, from https://shop.example weak methods: GET, POST, PUT, PATCH, DELETE; headers: content-type, x-debug-admin fixed methods: GET, POST; headers: content-type shared cache in front of the API, two pages ask for /me in turn weak second page reads the cached answer? no, it got Allow-Origin https://shop.example fixed second page reads the cached answer? yes
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node two_servers.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The weak middleware lets in https://eshop.example, plain http and null, three origins that are not the real one.
The fixed one answers only the two listed origins, and it sends Vary: Origin on every response. In the preflight, the
weak middleware echoes whatever the browser asked for, x-debug-admin included, and allows five methods, where the
fixed one lists the two methods and the one header its front end really uses. Echoing makes the permission follow the
request, so it checks nothing.
The cache shows what Vary is for. A response that names one origin depends on the request’s Origin, and a shared
cache that does not know this keeps one copy and hands it to the next origin. The weak middleware sent no Vary, so the
second page received an answer that named the first, and its script could not read it. The fixed middleware’s Vary: Origin made the cache keep one copy per origin. The lesson on safe caching in the next module treats Vary
in full.
Checkpoint
Check: Credentials, null and Vary
Allow one exact origin, or put the API on the origin of the page
The safest CORS setting is the one you do not need. Four designs avoid a cross-origin read, or make it harmless:
- One origin for the page and the API. Put the API under a path of the page’s own origin, with a reverse proxy, so that the browser sees one origin. No cross-origin read exists to misconfigure.
- A backend for the front end. The page calls a server of yours, and that server calls the API with its own credentials. The browser never holds the API token.
- A wildcard for public data.
Access-Control-Allow-Origin: *on a route that holds nothing private and needs no cookie. ASVS 3.4.2 allows a wildcard only when the response holds no sensitive data. - postMessage between two windows, which is safe only when every handler discards a message from an origin it does not trust, as ASVS 3.5.5 requires.
Where a page of another origin must call your API with a cookie, configure the exact list. Use your framework’s
maintained CORS middleware, give it whole origins, and do not use any setting that reflects the request’s Origin.
Send Vary: Origin, list only the methods and headers the front end uses, and keep Access-Control-Max-Age modest. A
model like this lesson’s is not a browser, so run a check against a real browser engine in your tests as well; the
standards have more cases than any model. Four questions about who calls your API choose between these designs:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Way | Safe for private data | Serves another site | Little to keep up | Few ways to go wrong |
|---|---|---|---|---|
| Same origin How it works: the API sits under the page's own origin | Good no cross-origin read exists, so nothing can be misconfigured | Poor a page on another site cannot call it with the user's cookie | Mixed a proxy rule or a shared host name to run | Good no origin to match, reflect or list |
| Backend for the front end How it works: the page calls your server, which calls the API | Good the server holds the token, and the browser never calls the API | Poor it serves your own page only | Poor one more server to build, secure and run | Good no CORS rules at all |
| CORS, exact list How it works: a list of whole origins, with Vary: Origin | Good with whole origins and Allow-Credentials true, only listed pages read the answer | Good each partner origin is added on purpose | Mixed a list to review whenever a partner changes | Mixed the matcher is where mistakes live: 1 of 10 matchers in the lesson was safe |
| CORS, wildcard How it works: Allow-Origin * and no credentials | Poor it cannot carry a cookie, and anything the browser can reach becomes readable | Good any page can read the answer | Good one header, set on the public routes only | Mixed one mistake: sending it on a route that holds private data |
| postMessage How it works: two windows that check each other's origin | Mixed safe only if every message checks event.origin (ASVS 3.5.5) | Good a page and a frame of different origins can talk | Mixed a message format to define and to version | Poor a missing origin check on one handler opens it |
How the answers decide
- “Who calls this API from a browser?” Only our own front end: rules out CORS, wildcard and adds a caveat to postMessage. Your own front end can share an origin with the API or talk to a server of yours, so no site needs a cross-origin read.
- “Who calls this API from a browser?” A few partner sites we know: rules out Same origin and Backend for the front end and adds a caveat to CORS, exact list. A partner's page is another origin, so it needs a CORS answer. List each partner's whole origin and review the list.
- “Who calls this API from a browser?” Any site, for public data: rules out Same origin, Backend for the front end and CORS, exact list. A list of every caller cannot be written down. Public data with no cookie is what a wildcard is for.
- “Does the call carry the user's cookie or private data?” Yes: rules out CORS, wildcard. A wildcard cannot go with a cookie, and on a route that also answers without one it lets any site read what the visitor's browser can reach.
In short: if your own front end is the only caller, avoid cross-origin reads altogether; if partner sites call it, keep an exact list that someone reviews; and keep the wildcard for public data without a cookie.
CORS is not CSRF protection
The headers above decide who may read answers. They do not make a form post safe, and a permissive rule can bring the forged requests of the CSRF lesson back: a cookie, a credentialed answer for an origin you do not control and a state change on the same route are all it takes.
Checkpoint
Check: An exact setup, or no CORS
Practise: a CORS middleware that allows only what it lists
The starter is close to the weak middleware of the third program, with a wildcard for other origins added, and it breaks several of the rules above at once. Rewrite it so that a listed origin gets exactly its own origin and credentials, every other origin gets nothing, and a preflight answer carries fixed lists. The sample tests include the lookalikes of this lesson:
Exercise · Medium · JavaScript, Python
Replace a CORS middleware that trusts too much
corsHeaders(req) (Python: cors_headers) returns the CORS headers of the response to one request, as an object with lower-case names. req holds the method and a headers object with lower-case names. The starter is close to the lesson's weak middleware, with a wildcard for other origins added, and it breaks several rules at once. Rewrite it to follow these:
- Every response carries
vary: "Origin", whatever the origin, because the answer depends on it. - When
originis exactly one ofALLOWED_ORIGINS, a whole-string comparison, addaccess-control-allow-originwith that origin andaccess-control-allow-credentialswith"true". For any other origin, including"null"and a missing header, add neither. - When the method is
OPTIONSand the origin is allowed, also addaccess-control-allow-methods(ALLOWED_METHODSjoined by", "),access-control-allow-headers(ALLOWED_HEADERSjoined by", ") andaccess-control-max-agewith"600". Never copy these from the request. - Never use
*ornullas a value.
JavaScript · Starter code · cors.mjs
export const ALLOWED_ORIGINS = ['https://shop.example', 'https://admin.shop.example'];
export const ALLOWED_METHODS = ['GET', 'POST'];
export const ALLOWED_HEADERS = ['content-type', 'x-csrf-token'];
/** The CORS response headers for one request: `req` is { method, headers } with lower-case header names. */
export function corsHeaders(req) {
const origin = req.headers.origin;
const h = {};
if (origin && (origin.endsWith('shop.example') || origin === 'null')) {
h['access-control-allow-origin'] = origin;
h['access-control-allow-credentials'] = 'true';
} else if (origin) {
h['access-control-allow-origin'] = '*';
}
if (req.method === 'OPTIONS') {
h['access-control-allow-methods'] = 'GET, POST, PUT, PATCH, DELETE';
h['access-control-allow-headers'] = req.headers['access-control-request-headers'] ?? '';
h['access-control-max-age'] = '86400';
}
return h;
} The sample tests · cors.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { ALLOWED_ORIGINS, corsHeaders } from './cors.mjs';
const get = (origin) => ({ method: 'GET', headers: origin === undefined ? {} : { origin } });
const preflight = (origin) => ({
method: 'OPTIONS',
headers: { ...(origin === undefined ? {} : { origin }), 'access-control-request-method': 'PUT', 'access-control-request-headers': 'content-type, x-debug-admin' },
});
const onlyVary = { vary: 'Origin' };
test('a listed origin gets exactly its own origin and credentials', () => {
for (const origin of ALLOWED_ORIGINS) {
assert.deepEqual(corsHeaders(get(origin)), { vary: 'Origin', 'access-control-allow-origin': origin, 'access-control-allow-credentials': 'true' });
}
});
test('an origin that is not listed gets nothing but Vary', () => {
assert.deepEqual(corsHeaders(get('https://other.example')), onlyVary);
assert.deepEqual(corsHeaders(get(undefined)), onlyVary, 'no Origin header at all');
});
test('lookalikes of a listed origin are refused', () => {
for (const origin of ['https://eshop.example', 'https://shop.example.other.test', 'http://shop.example', 'https://shop.example:8443', 'https://blog.shop.example']) {
assert.deepEqual(corsHeaders(get(origin)), onlyVary, origin);
}
});
test('null and the wildcard are never allowed', () => {
assert.deepEqual(corsHeaders(get('null')), onlyVary);
assert.deepEqual(corsHeaders(get('*')), onlyVary);
});
test('a preflight from a listed origin carries fixed lists, not what was asked for', () => {
assert.deepEqual(corsHeaders(preflight('https://shop.example')), {
vary: 'Origin',
'access-control-allow-origin': 'https://shop.example',
'access-control-allow-credentials': 'true',
'access-control-allow-methods': 'GET, POST',
'access-control-allow-headers': 'content-type, x-csrf-token',
'access-control-max-age': '600',
});
});
test('a preflight from any other origin gets nothing but Vary', () => {
assert.deepEqual(corsHeaders(preflight('https://eshop.example')), onlyVary);
assert.deepEqual(corsHeaders(preflight(undefined)), onlyVary);
});
test('a GET carries no preflight headers', () => {
const h = corsHeaders(get('https://admin.shop.example'));
for (const name of ['access-control-allow-methods', 'access-control-allow-headers', 'access-control-max-age']) {
assert.equal(h[name], undefined, name);
}
}); Python · Starter code · cors.py
ALLOWED_ORIGINS = ["https://shop.example", "https://admin.shop.example"]
ALLOWED_METHODS = ["GET", "POST"]
ALLOWED_HEADERS = ["content-type", "x-csrf-token"]
def cors_headers(req):
"""The CORS response headers for one request: req has "method" and "headers" (lower-case names)."""
origin = req["headers"].get("origin")
h = {}
if origin and (origin.endswith("shop.example") or origin == "null"):
h["access-control-allow-origin"] = origin
h["access-control-allow-credentials"] = "true"
elif origin:
h["access-control-allow-origin"] = "*"
if req["method"] == "OPTIONS":
h["access-control-allow-methods"] = "GET, POST, PUT, PATCH, DELETE"
h["access-control-allow-headers"] = req["headers"].get("access-control-request-headers", "")
h["access-control-max-age"] = "86400"
return h The sample tests · test_cors.py
from cors import ALLOWED_ORIGINS, cors_headers
ONLY_VARY = {"vary": "Origin"}
def get(origin):
return {"method": "GET", "headers": {} if origin is None else {"origin": origin}}
def preflight(origin):
headers = {"access-control-request-method": "PUT", "access-control-request-headers": "content-type, x-debug-admin"}
if origin is not None:
headers["origin"] = origin
return {"method": "OPTIONS", "headers": headers}
def test_a_listed_origin_gets_exactly_its_own_origin_and_credentials():
for origin in ALLOWED_ORIGINS:
assert cors_headers(get(origin)) == {
"vary": "Origin",
"access-control-allow-origin": origin,
"access-control-allow-credentials": "true",
}
def test_an_origin_that_is_not_listed_gets_nothing_but_vary():
assert cors_headers(get("https://other.example")) == ONLY_VARY
assert cors_headers(get(None)) == ONLY_VARY, "no Origin header at all"
def test_lookalikes_of_a_listed_origin_are_refused():
for origin in [
"https://eshop.example",
"https://shop.example.other.test",
"http://shop.example",
"https://shop.example:8443",
"https://blog.shop.example",
]:
assert cors_headers(get(origin)) == ONLY_VARY, origin
def test_null_and_the_wildcard_are_never_allowed():
assert cors_headers(get("null")) == ONLY_VARY
assert cors_headers(get("*")) == ONLY_VARY
def test_a_preflight_from_a_listed_origin_carries_fixed_lists():
assert cors_headers(preflight("https://shop.example")) == {
"vary": "Origin",
"access-control-allow-origin": "https://shop.example",
"access-control-allow-credentials": "true",
"access-control-allow-methods": "GET, POST",
"access-control-allow-headers": "content-type, x-csrf-token",
"access-control-max-age": "600",
}
def test_a_preflight_from_any_other_origin_gets_nothing_but_vary():
assert cors_headers(preflight("https://eshop.example")) == ONLY_VARY
assert cors_headers(preflight(None)) == ONLY_VARY
def test_a_get_carries_no_preflight_headers():
h = cors_headers(get("https://admin.shop.example"))
for name in ("access-control-allow-methods", "access-control-allow-headers", "access-control-max-age"):
assert name not in h, name A hint
Start from the headers every response gets, vary, and add the rest only inside one if that asks whether the origin is in ALLOWED_ORIGINS. A list lookup compares whole strings, so it cannot be fooled by an origin that merely starts or ends like a listed one. The preflight headers go inside a second if for OPTIONS.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS, Pyodide): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: JavaScript (about 0.6 MB) or Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Finish with the self-check. It asks about one idea at a time, starting with the one you know least, and each answer comes with its reason:
Practice that adapts to you
Practise this lesson
16 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what does CORS protect, and what does it not?
CORS lets a server say which other origins may read its responses, by relaxing the browser’s same-origin policy for
those origins. It protects nothing by itself: without it the browser withholds a cross-origin answer from the script, and
with it the browser releases the answer to the origins listed. It does not stop a request from being sent, so a simple
request such as a form post reaches the server whatever its CORS headers say, and it does not identify the caller, since
only browsers apply it. Use it sparingly: name whole origins from a list, never reflect the request’s Origin, never
allow null, send Vary: Origin, and keep session checks and CSRF defences in place whatever the headers say.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Your API echoes the request's Origin in Access-Control-Allow-Origin and sends Allow-Credentials true, because a developer once got a CORS error with a wildcard. A partner now asks you to add their site. What is wrong with the current setup, and how do you onboard partners safely?
-
Senior to staff
Your CORS check is the regular expression ^https://[a-z0-9-]+\.shop\.example$, so that every team's subdomain works. A scan finds a subdomain whose DNS record points at a hosting service that nobody on your team owns any more. What can happen, and how do you redesign the check?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does CORS change, and what did it never decide?
It lets a server relax the same-origin policy for the origins it names, so a page there may read the response. It never decided who may send a request: a simple request reaches the server whatever the headers say, and curl, other servers and mobile apps ignore CORS. It is not access control, and every allowed origin is a party you trust.
-
The console says a request was blocked by CORS policy. Did the request fail?
Not necessarily. For a simple request the server ran its code, and the response was withheld from the script. Look in the server's log before you conclude that the API did nothing.
-
Which headers does a preflight use, and what must the server answer?
The browser sends an OPTIONS request, without cookies, with Origin, Access-Control-Request-Method and Access-Control-Request-Headers. The server answers with Access-Control-Allow-Origin, Allow-Methods and Allow-Headers (and Max-Age); the real request follows only if they permit it. A route that demands a session on OPTIONS refuses every preflight.
-
How should a server decide that an Origin is its own?
Compare the whole Origin string with a list of whole origins and send back that origin exactly. A prefix, suffix, substring, unescaped regex dot, host-only comparison or reflected Origin each accepts something you did not mean, such as eshop.example, plain http or another port. A subdomain wildcard is as safe as your weakest subdomain.
-
What changes when a CORS request carries the user's cookie?
The answer must name one origin, with no * in Allow-Origin, Allow-Methods, Allow-Headers or Expose-Headers, and Access-Control-Allow-Credentials must be exactly true. Whether the cookie goes at all also depends on SameSite and the browser's third-party cookie policy. Reflecting Origin makes the error vanish by trusting every site.
-
Why is Access-Control-Allow-Origin: null never right, and what does Vary: Origin do?
The null origin identifies no site (sandboxed frames, files from disk, some form posts), and any site can make a document with a null origin, so allowing it lets every site in. A response that names one origin depends on the request's Origin; Vary: Origin makes a shared cache keep one copy per origin instead of handing the first origin's answer to the next.
-
Which designs avoid CORS risk, and what is the rule for a wildcard?
Put the API under the page's own origin with a reverse proxy, or use a backend for the front end so the browser never holds the API token. Allow * only for public data with no cookie (ASVS 3.4.2). Use postMessage only when every handler discards messages from untrusted origins (ASVS 3.5.5). Otherwise keep an exact list that someone reviews.
Key takeaways
- CORS relaxes the block on reading another origin’s response; it never decided who may send a request, and
curland other non-browser clients ignore it. - A simple request goes out at once and a preflight asks first, with
Access-Control-Request-MethodandAccess-Control-Request-Headersanswered by the matchingAllowheaders. - Compare the whole
Originstring with a list of whole origins: prefixes, suffixes, substrings, loose patterns, host names alone and a reflectedOrigineach accept an origin you did not mean, andnullis never ours. - With a cookie the answer must name one origin and send
Access-Control-Allow-Credentials: true, a wildcard does not work, and a response that depends onOriginneedsVary: Origin. - Prefer a design with no cross-origin read, one origin or a backend for the front end, and keep CORS, an exact list that someone reviews, for the pages that really need it.
Terms in this lesson
- credentialed request
- A cross-origin request that carries the user's cookies or other credentials, such as fetch() with credentials set to include. The server must name one exact origin and send Allow-Credentials true, because browsers refuse a wildcard answer.
- cross-origin resource sharing (CORS)
- Response headers by which a server tells browsers which other origins may read its answers. It relaxes the same-origin policy's block on reading. It does not stop requests from being sent and does not identify the caller.
- null origin
- The value null in an Origin header. Browsers send it for sandboxed frames, local files and some redirects, and a site's own form post can carry it under Referrer-Policy no-referrer. It names no site, so it is never a match.
References
- Cross-Origin Resource Sharing (CORS) (MDN Web Docs (Mozilla))
- Access-Control-Allow-Origin header (MDN Web Docs (Mozilla))
- Access-Control-Max-Age header (MDN Web Docs (Mozilla))
- Fetch Standard: the CORS protocol (WHATWG)
- HTML5 Security Cheat Sheet (Cross Origin Resource Sharing) (OWASP Foundation)
- A02:2025 Security Misconfiguration (mapped CWEs, CWE-942) (OWASP Foundation)
- CWE-942: Permissive Cross-domain Policy with Untrusted Domains (MITRE)
- ASVS 5.0.0, V3 Web Frontend Security (3.4.2, 3.5.1, 3.5.2 and 3.5.5) (OWASP Foundation)
- API8:2023 Security Misconfiguration (OWASP Foundation)
- Same-origin policy (MDN Web Docs (Mozilla))
- Vary header (MDN Web Docs (Mozilla))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress