Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 25 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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 Origin header 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 the browser does with fourteen answers from an API JavaScript · verdicts/browser_verdicts.mjs
// 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

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

  1. Question 1 of 2 In browser_verdicts.mjs the API answers GET /me with no CORS headers at all. What does the model say?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The browser sent the request, and the script may not read the response

    The first group of the table has sent: yes in every row. The server did its work each time, and only the script's view of the answer changed.

  2. Question 2 of 2 A developer sees "blocked by CORS policy" for a POST with a urlencoded body and decides that the order was not created. What is wrong with that?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A urlencoded POST is a simple request, so the server received it and may have created the order; only the response was withheld

    Read a CORS error for a simple request as "the script cannot see the answer". Check the server's log before you decide what happened.

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:

Interactive · message by message A preflighted PUT, from the call to the answer Step through one PUT with a JSON body and the user's cookie, and see where each check of the browser can end it.
A preflighted PUT, from the call to the answer, step 6 of 6. Messages so far: Browser to api.example: OPTIONS /me; api.example to Browser: 204 + Allow headers; Browser to api.example: PUT /me + cookie; api.example to Browser: 200 + Allow-Origin.Browserall four checks passapi.exampleyour CORS answersOPTIONS /me204 + Allow headersPUT /me + cookie200 + Allow-Origin

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 Which calls from a page on another origin need a preflight? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • A PUT with no body
    • A POST with Content-Type application/json
    • A GET with an Authorization header

    A request is simple only when its method is GET, HEAD or POST, it sets only safelisted headers and, for a POST, it has a form content type. Everything else is preflighted.

  2. Question 2 of 2 Why does a route that requires a valid session for every method break CORS for non-simple calls?
    Show the answer: Question 2 of 2

    Answer: The preflight is an OPTIONS request sent without cookies, so the route refuses it and the real request is never sent

    Answer OPTIONS from the CORS layer before authentication runs, and send only the Allow headers there.

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 is ours, on eight origins Python · matchers/origin_match.py
# 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

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

  1. Question 1 of 2 A matcher accepts an Origin when origin.endswith("shop.example"). Which origin from the matcher table does it accept although it is not the real one?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: https://eshop.example

    A suffix test accepts any host that happens to end the same way, and plain http as well, because the scheme is not part of the suffix.

  2. Question 2 of 2 The pattern https://shop.example, with the dot not escaped, is matched against the whole origin. Which origin does it wrongly accept?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: https://shop-example

    Escape the dot and anchor both ends, or better, skip patterns and compare the whole string with a list.

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:

A weak and a fixed CORS middleware, a preflight and a shared cache JavaScript · servers/two_servers.mjs
// 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

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

  1. Question 1 of 2 A page calls fetch() with credentials set to include, and the API answers with Access-Control-Allow-Origin * and Access-Control-Allow-Credentials true. What happens?
    Show the answer: Question 1 of 2

    Answer: The browser withholds the response from the script, because a wildcard cannot go with credentials

    With a cookie, the answer must name one exact origin and send Allow-Credentials true. That rule is why people reflect Origin, which makes the error disappear and trusts every site.

  2. Question 2 of 2 Why must a response that names one origin carry Vary Origin?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A shared cache could keep the copy for one origin and give it to a page on another, whose script then cannot read it

    A response that depends on a request header must say so. Without Vary, the cache cannot tell the origins apart.

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:

Interactive · answer and compare How should this page reach that API? Answer two questions about who calls your API. The table marks the ways that fit and says why the others do not.
Who calls this API from a browser?
Does the call carry the user's cookie or private data?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five ways to let a page reach an API, against what decides between them
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

  1. Question 1 of 2 Your only caller is your own front end, which you can serve from the same host as the API. What is the safest CORS setting?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: None: put both under one origin, so no cross-origin read exists

    A design that avoids the cross-origin read beats a careful configuration of one. The next best is the exact list.

  2. Question 2 of 2 A route returns a public product catalogue, with no cookie and no personal data. Which setting is right?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Access-Control-Allow-Origin * on that route only

    Keep the wildcard on the routes that hold nothing private, and keep the exact list on the others.

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:

  1. Every response carries vary: "Origin", whatever the origin, because the answer depends on it.
  2. When origin is exactly one of ALLOWED_ORIGINS, a whole-string comparison, add access-control-allow-origin with that origin and access-control-allow-credentials with "true". For any other origin, including "null" and a missing header, add neither.
  3. When the method is OPTIONS and the origin is allowed, also add access-control-allow-methods (ALLOWED_METHODS joined by ", "), access-control-allow-headers (ALLOWED_HEADERS joined by ", ") and access-control-max-age with "600". Never copy these from the request.
  4. Never use * or null as 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.

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.

CORS Tester Paste the response headers of your own API and the origin of your page, and read why a browser would block the call.

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.

  1. Question 1 of 16 What does CORS decide?

    Choose one answer.

    Show the answer: Question 1 of 16

    Answer: Whether a page on another origin may read a response

    CORS is a set of response headers that tell the browser which origins may read an answer. It is not a rule about who may send.

  2. Question 2 of 16 A program on a server calls your API with a valid token and no Origin header. What do your CORS headers do?

    Choose one answer.

    Show the answer: Question 2 of 16

    Answer: Nothing: CORS is applied by browsers, so the API's own checks judge the call

    CORS protects the person who uses a browser from other sites. It never stopped a program from calling your API.

  3. Question 3 of 16 A team says "CORS allows only our origin, so the API needs no authorisation checks". What is wrong?

    Choose one answer.

    Show the answer: Question 3 of 16

    Answer: CORS binds browsers only, and the users of the allowed page still differ in what they may do, so every request needs the API's own checks

    The Origin header and the CORS answer say which page may read. The session and the permission checks say what the caller may do.

  4. Question 4 of 16 Which header of a preflight names the method that the real request will use?

    Choose one answer.

    Show the answer: Question 4 of 16

    Answer: Access-Control-Request-Method

    The browser asks with the Request headers, and the server answers with the Allow headers.

  5. Question 5 of 16 A preflight answer carries no Access-Control-Max-Age header. For how many seconds may the browser reuse it?

    Type a number.

    Show the answer: Question 5 of 16

    Answer: 5 seconds

    The default is 5 seconds, according to the Fetch standard and MDN. Browsers also cap a larger value, at 2 hours in Chromium and 24 hours in Firefox.

  6. Question 6 of 16 Put the steps of a preflighted PUT in order.

    Give each item its position, from 1 (first).

    Show the answer: Question 6 of 16

    Answer:

    1. The script calls fetch() with the method PUT and a JSON body
    2. The browser sends OPTIONS with Origin and the two Request headers
    3. The API answers with the Allow headers
    4. The browser compares the answer with the request
    5. The browser sends the PUT with the cookie

    The real request waits for the comparison. If any check fails, the last step never happens.

  7. Question 7 of 16 The API answers a preflight for a PUT with a correct Access-Control-Allow-Origin and Access-Control-Allow-Credentials, and no Access-Control-Allow-Methods. What reaches the server next?

    Choose one answer.

    Show the answer: Question 7 of 16

    Answer: Nothing: the PUT is not sent, and the script gets a network error

    Every item the preflight asked about must be listed in the answer. A wildcard in the list does not count when the request carries a cookie.

  8. Question 8 of 16 What is wrong with answering Access-Control-Allow-Origin with the request's own Origin, and credentials true, for every request?

    Choose one answer.

    Show the answer: Question 8 of 16

    Answer: Every site, lookalikes included, may read the answers as the signed-in user

    A reflected origin makes the error go away by trusting everyone. Compare with a list instead.

  9. Question 9 of 16 In the matcher table, which matcher accepts only the real origin?

    Choose one answer.

    Show the answer: Question 9 of 16

    Answer: An exact string in a set of whole origins

    Only a comparison of whole strings with a list of whole origins leaves out every lookalike.

  10. Question 10 of 16 Your allowlist accepts the host and every subdomain. A team abandons staging.shop.example but leaves its DNS record, which now points at a service that anyone can claim. What does the CORS rule do now?

    Choose one answer.

    Show the answer: Question 10 of 16

    Answer: It trusts whoever claims that name, and a page there can read credentialed answers

    A rule for every subdomain is exactly as safe as the weakest subdomain. List the whole origins you need, and keep the list short.

  11. Question 11 of 16 Which Access-Control-Allow-Origin value can the answer to a credentialed request use?

    Choose one answer.

    Show the answer: Question 11 of 16

    Answer: One exact origin

    The server must choose the one origin that fits the request, which is why it sends Vary Origin.

  12. Question 12 of 16 Why is Access-Control-Allow-Origin null a mistake?

    Choose one answer.

    Show the answer: Question 12 of 16

    Answer: Any site can create a document whose origin is null, such as a sandboxed frame, so it lets every site in

    Null names no site. A rule that matches it matches pages that a hostile site can create at will.

  13. Question 13 of 16 A response names https://a.example in Access-Control-Allow-Origin and has no Vary. A shared cache keeps it. What happens to a page on https://b.example that asks for the same URL?

    Choose one answer.

    Show the answer: Question 13 of 16

    Answer: It receives the copy that names a.example, so its script cannot read it

    The program's last lines show it. With Vary Origin the cache keeps one copy per origin.

  14. Question 14 of 16 What should the entries of an origin allowlist be?

    Choose one answer.

    Show the answer: Question 14 of 16

    Answer: Whole origins, with scheme, host and port

    Write the list the way the Origin header is written, and compare strings.

  15. Question 15 of 16 A partner's website must call your API with the user's cookie. Which setting from the lesson fits?

    Choose one answer.

    Show the answer: Question 15 of 16

    Answer: An exact list that holds the partner's whole origin, with credentials and Vary Origin

    Choose by who calls the API. The partner's origin goes on the list, and someone reviews the list when partners change.

  16. Question 16 of 16 Why does the lesson ask for a check against a real browser engine as well as a model like its own?

    Choose one answer.

    Show the answer: Question 16 of 16

    Answer: A model covers the cases written into it, and a browser applies the whole standard as shipped

    Use the model to understand and to write fast tests, and a real engine to confirm that the deployed headers behave as you expect.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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 curl and other non-browser clients ignore it.
  • A simple request goes out at once and a preflight asks first, with Access-Control-Request-Method and Access-Control-Request-Headers answered by the matching Allow headers.
  • Compare the whole Origin string with a list of whole origins: prefixes, suffixes, substrings, loose patterns, host names alone and a reflected Origin each accept an origin you did not mean, and null is 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 on Origin needs Vary: 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.