cf.completefrontend
My progress
FUNDAMENTALS · MODULE 11

CORS

The rules that let a page read responses from another origin, and the headers a server sends to allow it: a browser feature that relaxes the same-origin policy to protect users' data, not a wall that protects your server.

The problem CORS solves

Picture a user named Priya. In one tab she is logged in to her bank at https://bank.example. Her browser holds a session cookie for that site. In another tab she opens a recipe blog that, without her knowing, runs a script written by an attacker.

The script calls fetch("https://bank.example/api/accounts", { credentials: "include" }). Say the bank set its session cookie with SameSite=None, which lets it travel with requests started by any site. The browser attaches it, and the bank sees a valid session and answers with her account numbers and balances.

If the script could now read that answer, any page on the web could read any data from any site you are logged in to: your email, your messages, your company's internal tools. The web would not be usable. Something has to stand between the response and the script.

The browser's answer: the same-origin policy

That something is the same-origin policy, a rule built into every browser since the mid-1990s. It says: a page may send requests to other sites, but it may only read responses that come from its own origin. The recipe blog's script can make the request. It cannot see what comes back.

This keeps Priya safe, but it also blocks honest apps. Her bank's own web app might live at https://app.bank.example and call an API at https://api.bank.example. Those are different origins, so by default the app cannot read its own API's answers either.

CORS, Cross-Origin Resource Sharing, is the way out. It is a set of HTTP headers the server sends to tell the browser: "pages from this origin may read my responses". The browser checks those headers and, only if they allow it, hands the response to the script.

So CORS does not add protection. It removes some of the protection the same-origin policy gives, in a controlled way.

A sequence diagram between a script on a recipe blog, Priya's browser, and bank.example. The script calls fetch for the accounts API with credentials include. The browser sends GET /api/accounts with Priya's bank session cookie. The bank answers 200 with her balances and no Access-Control-Allow-Origin header. The browser gives the script a TypeError instead of the data, because of the same-origin policy.blog scriptbrowserbank.examplefetch, includeGET /api/accountsCookie: session=p91200 + balancesno Allow-OriginTypeErrorsame-origin policy: the read is blocked
The request went out and the bank answered with real data. The same-origin policy is the only thing between that answer and the blog's script.

Why CORS confuses so many people

CORS errors are some of the most common and least understood messages in the browser console. Three facts cause most of the confusion, and this lesson returns to each of them.

  • The browser enforces it, not the server. The server only sends headers. The browser reads them and decides whether your code sees the response. A CORS error means the browser refused, often after the server already did the work and returned 200.
  • It is about reading, not sending. Many cross-origin requests reach the server even when CORS fails. So CORS is not a defense against requests that change data, which is the job of CSRF defenses.
  • It protects users, not servers. curl, a Python script, or an attacker's own server ignore CORS entirely. CORS only stops one website from using a visitor's browser, and the visitor's cookies, to read another website's data.

The rest of the lesson builds the mental model of origins, walks through the exact headers of simple and preflighted requests, explains credentials, and then covers the mistakes that turn CORS into a data leak. At the end you will be able to predict, for any request and any server policy, whether a preflight happens and whether your code can read the answer.

Origins and what the same-origin policy blocks

Before any header makes sense, you need a precise idea of an origin. An origin is three parts of a URL: the scheme (https), the host (app.example.com), and the port (443). Two URLs have the same origin only if all three match exactly. The path, the query string, and the fragment do not count.

The URL https://app.example.com:443/orders?page=2#top split into six parts. The scheme https, the host app.example.com, and the port 443 together are the origin, and all three must match. The path /orders, the query ?page=2, and the fragment #top are ignored for origin checks. Default ports are 443 for https and 80 for http.httpsschemeapp.example.comhost443port/orderspath?page=2query#topfragmentthe origin: all three must matchignored for origin checksdefault ports: 443 for https, 80 for http
Only the first three parts count. When the port is left out, the browser fills in the default, so https://app.example.com and https://app.example.com:443 are one origin.

When the port is left out, the browser uses the scheme's default: 443 for https and 80 for http. So https://shop.example and https://shop.example:443 are the same origin, while https://shop.example:8443 is a different one.

Compared with https://app.example.comSame origin?Why
https://app.example.com/orders?page=2YesOnly the path and query differ
http://app.example.comNoDifferent scheme
https://api.example.comNoDifferent host, even though the site is the same
https://app.example.com:8443NoDifferent port
https://example.comNoThe parent domain is a different host

Origin is not the same as site

A site is looser: the scheme plus the registrable domain, which is the part you buy from a registrar, like example.com. app.example.com and api.example.com are same-site but cross-origin. This difference matters a lot. Cookie rules such as SameSite work on sites, while CORS works on origins.

So a request from app.example.com to api.example.com is cross-origin, and needs CORS, yet a SameSite=Lax cookie is still sent with it because the two are same-site. The CSRF lesson explains the cookie side in depth. For CORS, always think in exact origins.

What is blocked: reading, not sending

The same-origin policy is often described as "pages cannot talk to other origins". That is wrong, and the mistake causes real security bugs. Pages talk to other origins all the time: every image from a CDN, every analytics script, every embedded video is a cross-origin request.

The rule is narrower. A page may send and embed across origins, but it may not read the raw data of another origin. The browser lets an img tag show a picture from anywhere, but it will not let your script read that picture's pixels. A form can post to any URL, but the page cannot read the response.

A matrix of six things a page on another origin can do, with whether the request is sent and whether the page can read the result. An img tag: sent, but the pixels cannot be read. A script tag: sent, the source cannot be read. A form post: sent, the response cannot be read. A simple GET fetch: sent, readable only with CORS. A fetch PUT with JSON: sent only after a preflight, readable only with CORS. An iframe: loaded, but its DOM can never be read, CORS or not.request sent?page reads it?img tag shows a pictureyesno: pixelsscript tag runs codeyesno: sourceform posts to the siteyesnosimple fetch (GET)yesonly with CORSfetch PUT with JSONafter preflightonly with CORSiframe of the siteyesnever: DOM
The left column is almost all yes, the right column almost all no. CORS only turns some of the right column into yes; reaching into another origin's DOM needs postMessage instead.
  • Allowed without CORS: showing images, running scripts, applying stylesheets, playing media, and embedding iframes; submitting forms; following links; sending fetch requests that count as simple.
  • Blocked without CORS: reading a fetch or XMLHttpRequest response body or most of its headers; reading pixels from a cross-origin image drawn on a canvas; reading the DOM of a cross-origin iframe; reading detailed error messages from a cross-origin script.
  • Never relaxed by CORS: reaching into a cross-origin iframe's document or another window's DOM. That needs postMessage, a separate opt-in channel.

Notice that the first group includes simple fetch requests: the browser does send them. The server receives them, runs the handler, and replies, and only the reply is hidden from the page. Hold on to that: it is why CORS can never be your defense against requests that change data.

Check your understanding · Exercise 1

A page at https://app.example.com wants to read responses. Which of these targets are cross-origin, so they need CORS? Select all that apply.

How a cross-origin request works, step by step

Take the friendliest case first. A page at https://app.example.com runs fetch("https://api.example.com/v1/products"). No cookies, no custom headers, a plain GET. Here is exactly what happens.

  1. The browser compares origins. The page's origin and the URL's origin differ, so the request runs in CORS mode. fetch uses mode cors by default.
  2. The browser adds an Origin header. It names the page's origin: Origin: https://app.example.com. Your script cannot set or fake this header; the browser owns it.
  3. The browser decides the request is simple (the next chapter covers the rules) and sends it right away, with no extra round trip.
  4. The server handles the request like any other and adds Access-Control-Allow-Origin to the response if it wants this origin to read it.
  5. The browser runs the CORS check. If the response header is * or exactly matches the Origin it sent, the check passes. Otherwise it fails.
  6. The page gets the result. On a pass, the fetch promise resolves with a normal Response. On a fail, it rejects with a TypeError, and the console logs a CORS error. The script cannot tell a CORS failure apart from a network failure, on purpose.
A sequence diagram between page code on app.example.com, the browser, and api.example.com. The page calls fetch. The browser sends GET /v1/products with an Origin header of https://app.example.com. The API answers 200 with JSON and Access-Control-Allow-Origin set to the same origin. The browser compares the two, the check passes, and the page receives the response.page codebrowserapi.example.comfetch(url)GET /v1/products+ Origin200 + JSON+ Allow-Origincheck passesResponse
One round trip, no extra request. The browser adds Origin: https://app.example.com on the way out and compares it with Access-Control-Allow-Origin on the way back; here they hold the same value.
http
GET /v1/products HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Accept: application/json

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin

[{"id":1,"name":"Lamp"}]
The browser adds Origin; the server's Access-Control-Allow-Origin decides whether the page may read the body.

The check is a plain string comparison

The browser does not parse or guess. It compares the value of Access-Control-Allow-Origin with the serialized origin of the page, character by character. https://app.example.com/ with a trailing slash fails, and so does a value with a path or the wrong scheme.

The header also takes exactly one value. You cannot send Access-Control-Allow-Origin: https://a.example, https://b.example: the browser treats the whole string as one origin, which never matches, so the check fails for everyone. Servers that allow several origins must pick the one that matches the request and echo it, which chapter 6 covers.

Why CORS errors feel strange

Open the Network panel after a failed simple request and you will often see a 200 with a full body. Your server logs show success. Yet your code got a TypeError. Both are true: the server answered, and the browser refused to hand the answer to the page.

The browser hides the reason from JavaScript on purpose. If a script could read "failed because the server sent Access-Control-Allow-Origin: https://intranet.corp", it would learn things about the target. Only the console, which the user sees and the page does not, shows the detailed message.

Same-origin requests skip all of this

When the page and the URL share an origin, there is no CORS check, no preflight, and every response header is readable. Browsers still send an Origin header on same-origin POST requests and on some others, which servers can use for CSRF checks, but no Access-Control-* header is needed. This is the reason "put the API on the same origin" is the simplest CORS fix of all.

Check your understanding · Exercise 2

A page at https://app.example.com makes a simple GET to https://api.example.com. Which responses let the page read the body? Select all that apply.

Simple requests and preflights

Not every cross-origin request is sent right away. The browser splits them into two groups. Simple requests go out at once, like the GET in the last chapter. Everything else first gets a preflight: a small OPTIONS request that asks the server for permission before the real request is sent.

Why there are two kinds

The split comes from history. Before CORS existed, an HTML form on any site could already send a GET or POST to any server, with a form-encoded body and the user's cookies. Servers had lived with that for years. So letting fetch send the same kind of request adds no new risk: an attacker could already do it with a form.

A DELETE, a JSON body, or an Authorization header is different. No form could send those, so old servers might assume they only ever come from their own pages. The preflight protects those servers: the browser asks first, and a server that knows nothing about CORS will not say yes, so the real request is never sent.

The exact rules for a simple request

A request is simple only when every rule below holds. Break one and the browser preflights.

  1. The method is GET, HEAD, or POST. PUT, PATCH, and DELETE always need a preflight.
  2. Every header the script sets is CORS-safelisted. That means Accept, Accept-Language, Content-Language, Content-Type (with a limited value, below), and Range with a single simple byte range. Headers the browser adds itself, like User-Agent or Accept-Encoding, do not count. Any custom header, such as Authorization, X-Requested-With, or X-CSRF-Token, makes it preflighted.
  3. Content-Type, if set, is one of three values: application/x-www-form-urlencoded, multipart/form-data, or text/plain. application/json is not on the list.
  4. The safelisted values are short and plain. Each value must avoid certain unusual characters and be at most 128 bytes long, and the values of all safelisted headers together at most 1,024 bytes. Real apps rarely hit this.
  5. No upload progress listener and no streaming body. Adding an event listener on XMLHttpRequest.upload, or sending a ReadableStream as the body, forces a preflight.
A flowchart of how the browser classifies a cross-origin request. First: is the method GET, HEAD, or POST? Second: does the script set only safelisted headers? Third: is the Content-Type a form type or text/plain? A no to any question leads to a preflight, an OPTIONS request first. Yes to all three leads to a simple request, sent at once.GET, HEAD, or POST?noyesonly safelisted headers?noyesContent-Type form or text?noyespreflightOPTIONS firstsimple: send at once
One no is enough for a preflight. A POST with application/json fails the third question; a GET with Authorization fails the second. An upload progress listener or a streamed body also forces one.

The practical result surprises people: almost every modern API call is preflighted. A POST with Content-Type: application/json is not simple. Neither is a GET with an Authorization: Bearer ... header. Only plain reads without custom headers, and form-style posts, skip the preflight.

What a preflight looks like

Say the page sends fetch(url, { method: "PUT", headers: { "Content-Type": "application/json", "Authorization": "Bearer abc" }, body }). Before the PUT, the browser sends this on its own:

http
OPTIONS /v1/orders/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization,content-type

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 7200
Vary: Origin
The preflight asks "may I send a PUT with these two headers?". Header names arrive in lowercase, sorted, and comma-separated.

The browser checks the preflight answer in four ways. The status must be a success (2xx; 204 No Content is common), and Access-Control-Allow-Origin must match, as before. The method must be in Access-Control-Allow-Methods (or be GET, HEAD, or POST). Every requested header must be in Access-Control-Allow-Headers, compared without regard to case.

If all four pass, the browser sends the real PUT, with an Origin header again, and the real response must also carry a matching Access-Control-Allow-Origin. If any check fails, the real request is never sent, and fetch rejects with a TypeError. That is the one case where CORS does stop a request from reaching the server.

A sequence diagram between the browser and api.example.com over two round trips. Round trip one: the browser sends OPTIONS /v1/orders/42 with Access-Control-Request-Method PUT and Access-Control-Request-Headers authorization and content-type. The API answers 204 with the allowed methods, headers, and a max age. Round trip two: the browser sends the real PUT /v1/orders/42, and the API answers 200 with Access-Control-Allow-Origin, so the page can read it.browserapi.example.comtrip 1OPTIONS /v1/orders/42Request-Method: PUTRequest-Headers:authorization,content-type204 allowedAllow-Methods, Allow-Headers, Max-Agetrip 2PUT /v1/orders/42 + body200 + Allow-Origin
Two round trips instead of one. The preflight carries no cookies and no body; if its answer does not allow the method and headers, the PUT is never sent.

Details that trip people up

  • Preflights carry no cookies or Authorization. The browser never sends credentials on an OPTIONS preflight. A server that checks login on every route, OPTIONS included, answers 401, and the browser treats that as a failed preflight. Answer OPTIONS before the auth middleware runs.
  • The preflight is per request shape. Adding one more custom header changes Access-Control-Request-Headers, which needs a fresh answer from the server.
  • Redirects and preflights do not mix well. If the preflight gets a 301 or 302, the browser fails it. Point clients at the final URL, including the trailing slash your framework expects.
  • Proxies and CDNs must pass OPTIONS through. A gateway that rejects unknown methods, or caches an OPTIONS answer for one origin and replays it to another, breaks preflights in ways that look random.

The request

Page origin
Method
Body
Extra header
Cookies

The server policy

Allow-Origin
Allow-Methods
Allow-Headers
Allow-Credentials
PUT with a JSON body and an Authorization header from https://app.example.com, without cookies. It needs a preflight. Result: real request never sent.browserapi.example.comOPTIONS: PUT + authorization,content-type204: not allowedreal request never sent
The preflight failed (authorization not in Allow-Headers), so the browser never sent the PUT. Nothing ran on the server.
Preflightyes: PUT is not simple
Real request reaches the serverno
JavaScript can read the responseno: never sent

Check your understanding · Exercise 3

From https://app.example.com to https://api.example.com, which requests trigger a preflight? Select all that apply.

The response headers, one by one

The browser sends at most three CORS request headers (Origin, and on a preflight Access-Control-Request-Method and Access-Control-Request-Headers). The server's side has six. Each answers one question the browser asks, and each has a classic mistake.

HeaderSent onAnswersTypical value
Access-Control-Allow-OriginPreflight and real responseWhich origin may read this?https://app.example.com or *
Access-Control-Allow-MethodsPreflightWhich methods may the real request use?GET, POST, PUT, DELETE
Access-Control-Allow-HeadersPreflightWhich request headers may the script set?Authorization, Content-Type
Access-Control-Allow-CredentialsPreflight and real responseMay the request carry cookies, and may the page read the result?true (or leave it out)
Access-Control-Expose-HeadersReal responseWhich extra response headers may the script read?ETag, Link, X-Request-Id
Access-Control-Max-AgePreflightHow long may the browser reuse this preflight answer?7200 (seconds)

Access-Control-Allow-Origin

The gate itself. It holds either * (any origin, only for requests without credentials) or exactly one origin. It must be on the real response, not only on the preflight; forgetting it on the real response is a common bug in hand-written handlers.

Remember error responses too. If your framework adds CORS headers only on success, a 400 or 500 comes back without them, and the page sees a vague network error instead of your helpful JSON error body. Add the headers in middleware that runs for every response.

Access-Control-Allow-Methods and -Headers

These only matter on the preflight. They list what the real request may use. Both accept * as a wildcard for requests without credentials, with one exception: * in Access-Control-Allow-Headers never covers Authorization. You must name it explicitly, which stops a lazy wildcard from accidentally allowing bearer tokens.

The common mistake is a list that drifts from the client. A frontend engineer adds an X-Client-Version header for logging, every request starts failing its preflight in production, and the API team has never heard of the header. Keep the list in one shared place, and test it in staging with the real origin.

Access-Control-Expose-Headers

Even when the read is allowed, your script sees only seven response headers by default, the CORS-safelisted response headers: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, and Pragma. Everything else returns null from response.headers.get(), even though the Network panel shows it.

So if your client needs ETag for conditional updates, Link for pagination, Retry-After for backoff, or X-Request-Id for bug reports, the server must list them in Access-Control-Expose-Headers. Set-Cookie can never be exposed, whatever you list.

Access-Control-Max-Age and the preflight cache

A preflight costs a full round trip before the real request: 50 to 150 ms on a typical mobile connection, more across continents. Access-Control-Max-Age lets the browser cache the preflight answer for a number of seconds, so later requests skip it.

Browsers cap the value. Chrome and other Chromium browsers use at most 2 hours (7,200 seconds), and Firefox at most 24 hours (86,400 seconds). Without the header, the answer is cached for only 5 seconds. Setting 86400 is fine; Chrome simply treats it as 7,200.

The cache key matters more than the duration. Entries are stored per origin and per URL, plus whether credentials were used. So PUT /orders/41 and PUT /orders/42 each need their own preflight. A REST API with IDs in its paths may preflight almost every first call, whatever the max age.

A timeline of four PUT calls at a 100 millisecond round trip, drawn to scale. Same origin: 100 ms, one request. Cross-origin first call: a 100 ms OPTIONS preflight, then the 100 ms PUT, 200 ms total. Same URL again with the preflight cached: 100 ms. A new URL, /orders/43: the preflight cache does not cover it, so 200 ms again.same originPUTcross-origin, firstOPTIONSPUTsame URL, cachedPUTnew URL /orders/43OPTIONSPUT0 ms100 ms200 ms
The preflight cache is keyed by URL. Access-Control-Max-Age helps repeat calls to one URL, but an API with IDs in its paths pays the extra round trip on every new ID.

Check your understanding · Exercise 4

A cross-origin fetch succeeds, but response.headers.get("X-Total-Count") returns null even though DevTools shows the header. What fixes it? Pick one.

Credentials: cookies and the wildcard rule

Everything so far assumed requests without the user's identity. Most real apps need the opposite: the API must know who is calling. Here CORS gets stricter, and for good reason, because this is the exact case of Priya's bank from chapter 1.

The credentials mode

In CORS, credentials means cookies, HTTP authentication, and TLS client certificates: things the browser attaches on its own. An Authorization header that your code sets by hand is not a credential in this sense; it is just a custom header, which needs Access-Control-Allow-Headers instead.

fetch has a credentials option with three values. "same-origin" is the default: cookies go with same-origin requests only. "include" sends them cross-origin too, and "omit" never sends them. With XMLHttpRequest, setting withCredentials = true is the same as "include".

ts
// Sends the api.example.com cookies along with the request.
const res = await fetch("https://api.example.com/v1/me", {
  credentials: "include",
});
Opting in to cookies on a cross-origin request.

What the server must send back

With credentials: "include", the browser adds two rules to the check. Both apply to the preflight and to the real response.

  1. Access-Control-Allow-Credentials: true must be present. Without it, the browser blocks the read.
  2. Access-Control-Allow-Origin must be the exact origin, never *. A wildcard with credentials fails. The same goes for * in Access-Control-Allow-Methods, -Headers, and -Expose-Headers: with credentials, the browser treats * as a literal header or method named "*", which matches nothing useful.

Why ban the wildcard? Because * is meant for public data, like fonts or an open weather API. Combining it with cookies would mean "any site on the internet may read this user's private data using their session". The rule forces the server to name each trusted origin, so a copy-pasted * cannot leak user data by accident.

Allowing several origins: check and echo

Since the header takes one exact origin, a server that serves app.example.com and admin.example.com has to choose per request. It reads Origin, checks it against an allowlist, and echoes it back only if it is on the list. If not, it sends no Access-Control-Allow-Origin at all.

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

export function corsHeaders(origin: string | null): Record<string, string> {
  // Vary on every response, matched or not.
  const headers: Record<string, string> = { Vary: "Origin" };
  if (origin !== null && ALLOWED.has(origin)) {
    headers["Access-Control-Allow-Origin"] = origin;
    headers["Access-Control-Allow-Credentials"] = "true";
  }
  return headers;
}
An exact-match set, an echo only on a match, and Vary: Origin always.

Why Vary: Origin is not optional

Once the response depends on the request's Origin, any cache in between must know it. Vary: Origin tells the browser cache and every CDN "store a separate copy per Origin value". Without it, a CDN may cache the response that says Access-Control-Allow-Origin: https://admin.example.com and serve it to users of app.example.com, whose check then fails.

The symptom is maddening: CORS works for some users and not others, changes after a deploy, and cannot be reproduced locally where there is no CDN. Send Vary: Origin on every response whose CORS headers depend on the origin, including the ones where the origin did not match.

Two CDN caches side by side for the URL /v1/me. Without Vary: Origin, the cache keeps one copy, stored when an admin.example.com user asked, with Allow-Origin set to admin. An app.example.com user then gets that copy and the CORS check fails. With Vary: Origin, the cache keeps one copy per Origin value: one with Allow-Origin admin and one with Allow-Origin app, so each user gets a matching header.without Vary: Origincache keystored header/v1/meAllow: adminapp user gets admin's copyapp user: CORS failswith Vary: Origincache keystored header+ adminAllow: admin+ appAllow: appone copy per Origineach user: a match
The server echoed the right origin both times; the cache broke it. Vary: Origin makes Origin part of the cache key, so each origin gets its own copy.

CORS cannot override cookie rules

Perfect CORS headers do not guarantee the cookie is sent. Cookie rules run first. If the app and the API are on different sites, say app.example.com and api.example-cloud.net, the API's cookie is a third-party cookie from the page's point of view: a cookie for a site other than the one in the address bar. It needs SameSite=None; Secure, and even then the browser may hold it back.

Safari blocks third-party cookies by default. Firefox partitions them by default, keeping a separate cookie jar per top-level site, so the API gets a different, empty jar instead of the cookie the user got when logging in on the API's own site. Chrome still sends them by default, but users can turn them off.

The dependable fix is to keep the API same-site: api.example.com next to app.example.com. Then a SameSite=Lax cookie flows with the request, and CORS only has to allow the exact origin. Better still, put the API on the same origin, which the last chapters cover.

Check your understanding · Exercise 5

A page sends fetch(url, { credentials: "include" }) cross-origin. Which server responses let the page read the body? Pick one.

Common misconfigurations

CORS bugs rarely break the app. They quietly let other sites read data they should not. Security audits and bug bounty reports find the same handful again and again. Each one below follows the same pattern: the server says "yes" to an origin an attacker controls, with credentials allowed.

1. Reflecting any origin

The worst and most common. Someone hits a CORS error, finds that * does not work with cookies, and writes code that copies the request's Origin straight into Access-Control-Allow-Origin, with Access-Control-Allow-Credentials: true. The error goes away. So does all the protection.

Now https://evil.example can run fetch("https://api.example.com/v1/me", { credentials: "include" }) from any visitor's browser, and the server will invite it to read the answer. This is exactly the attack from chapter 1, made possible by the server itself. Many CORS libraries do this when configured with origin: true; read your library's docs carefully.

2. Trusting the null origin

Sometimes the browser sends Origin: null. That happens for pages loaded from file:// URLs, for data: URLs, for documents in a sandboxed iframe (sandbox without allow-same-origin), and after some cross-origin redirects. Developers see null while testing a local HTML file and add it to the allowlist.

But any attacker can produce Origin: null on purpose, by running their script inside a sandboxed iframe on their own page. So Access-Control-Allow-Origin: null with credentials means "anyone may read". Never allow null.

3. Pattern-matching bugs

Teams with many origins, like preview deploys at pr-123.app.example.com, reach for patterns instead of a list. Patterns are easy to get subtly wrong. Each check below looks reasonable and fails in a way an attacker can register a domain for.

  • origin.endsWith("example.com") accepts https://notexample.com, which anyone can buy. A suffix check must include the dot: .example.com.
  • origin.startsWith("https://app.example.com") accepts https://app.example.com.evil.net, a subdomain of the attacker's domain.
  • origin.includes("example.com") accepts both of those and more.
  • The regex /^https:\/\/.*.example.com$/ has an unescaped dot, so https://evilexample.com matches, because . means any character.

The safe approach: parse the origin with new URL(origin), require protocol === "https:", and then compare the hostname against an exact set, or check that it equals the base domain or ends with "." + base. Better still, keep a plain list and generate entries for preview deploys from your deploy tooling.

4. Trusting every subdomain

Even a correct *.example.com check trusts a lot. Large companies run hundreds of subdomains: old marketing microsites, a support forum, a status page hosted by a vendor, a forgotten demo. If any one of them has an XSS hole, or a dangling DNS record that an attacker can take over, the attacker's script runs on a trusted origin and reads your API with the user's cookies.

So the allowlist for a credentialed API should name the few origins that really call it, such as app.example.com and admin.example.com, not the whole domain. Add http://localhost:3000 only in development builds of the server, never in production.

5. The smaller mistakes

  • Missing Vary: Origin with a reflected origin: random failures behind a CDN, as chapter 6 showed.
  • Allowing http:// origins for an https API: a network attacker on public Wi-Fi can inject a script into the plain-HTTP page and read through it.
  • CORS on internal admin APIs "just in case": every allowed origin is attack surface. Allow nothing until a real caller needs it.
  • Access-Control-Allow-Origin: * on endpoints that use other ambient trust, such as an IP allowlist on an intranet API. The user's browser sits inside the network; a public page can then read intranet data through it.
Origin check
allowed to read with cookies
With the check endsWith("example.com"), 4 of 6 untrusted origins may read the credentialed API. https://app.example.com: allowed. https://notexample.com: allowed. https://app.example.com.evil.net: blocked. https://evilexample.com: allowed. http://app.example.com: allowed. https://old-blog.example.com: allowed. null: blocked.https://app.example.comyour appallowedhttps://notexample.comattacker buys itallowedhttps://app.example.com.evil.netattacker subdomainblockedhttps://evilexample.comattacker buys itallowedhttp://app.example.complain httpallowedhttps://old-blog.example.comforgotten siteallowednullsandboxed iframeblocked
4 untrusted origins get in. Each red row can read every logged-in user's data from the API.

Check your understanding · Exercise 6

A credentialed API trusts origins with origin.endsWith("shop.com"). Which origins can read user data? Select all that apply.

no-cors, crossorigin, and private networks

CORS is not only about fetch calls to APIs. It also decides what you get back in no-cors mode, whether a canvas can read an image, whether a font loads, and, in newer browsers, whether a public page may talk to your router. This chapter covers those corners.

mode: "no-cors" and opaque responses

When developers meet a CORS error, a search often suggests fetch(url, { mode: "no-cors" }). The error disappears. The data does not. no-cors tells the browser "send this like an img tag would, and I promise not to read the result".

The browser keeps that promise for you. It only allows simple methods and safelisted headers, and it hands back an opaque response: type is "opaque", status is 0, ok is false, the headers are empty, and the body reads as nothing. Your code cannot even tell a 200 from a 404.

Two Response objects field by field. With mode cors and a passing check: type cors, status 200, ok true, headers limited to the safelisted and exposed ones, and a readable body. With mode no-cors: type opaque, status 0, ok false, empty headers, and a body that reads as empty to JavaScript.mode: cors, allowedmode: no-corstype"cors""opaque"status2000oktruefalseheaderssafelisted + exposedemptybodyreadableempty to JS
An opaque response hides even its status, so code cannot tell a 200 from a 404. Only tags like img, or a service worker passing it through, can use it.

Opaque responses still have uses. A service worker can store them in the Cache API and serve them later to an img or script tag, which only need to display or run them. The catch: since the size is hidden, browsers count each opaque entry as much larger than it is against the storage quota (about 7 MB each in Chrome), so caching many of them fills the quota quickly. For anything your code needs to read, fix the server's CORS headers instead.

The crossorigin attribute

Tags like img, script, link, audio, and video load cross-origin files without CORS by default. Adding the crossorigin attribute switches them to a CORS request, which unlocks reading. It takes two values: anonymous (no cookies) and use-credentials (with cookies, needing the credentials rules from chapter 6).

  • Canvas. Drawing a cross-origin image without CORS taints the canvas: getImageData() and toDataURL() throw a SecurityError. Load it with crossorigin="anonymous" from a server that sends Access-Control-Allow-Origin, and the canvas stays readable. Photo editors and WebGL textures depend on this.
  • Fonts. Web fonts loaded with @font-face from another origin always use CORS. A font CDN without Access-Control-Allow-Origin means the font silently fails and text falls back to a system font.
  • Script errors. A cross-origin script without CORS reports every error to window.onerror as just "Script error.", with no message or stack. Error tracking tools ask you to add crossorigin="anonymous" to script tags and CORS headers on the CDN for this reason.
  • Preload must match. A link rel="preload" for a font or a module script needs the same crossorigin value as the real request, or the browser downloads the file twice.

Private Network Access

The same-origin policy has a gap: a public web page can still send simple requests to devices on your home or office network, like http://192.168.1.1/ for a router or http://localhost:8080 for a dev tool. Researchers have used this to attack routers and printers through visitors' browsers, because those devices often trust anything from the local network.

Chrome has worked on closing this gap for several years. The first design, Private Network Access, added a preflight with Access-Control-Request-Private-Network: true that the local device had to answer with Access-Control-Allow-Private-Network: true. Chrome later paused the enforcement of those preflights and, in 2025, moved towards a different design, Local Network Access, where the user grants the page a permission instead. Treat the details as still changing and check the current status before you rely on it.

The lasting lesson holds either way: a local service that trusts "requests from my network" is trusting every website the user visits. Local tools should require a token or check the Origin header themselves.

Check your understanding · Exercise 7

A page calls fetch("https://partner.example/data.json", { mode: "no-cors" }). What is true? Select all that apply.

CORS protects users, not your server

This is the idea interviewers probe most, and the one most engineers get backwards. CORS is not a firewall for your API. It is a rule the browser follows to protect the person using it.

Who is protected from whom

The threat CORS handles is narrow: a page from one origin using a visitor's browser, with that visitor's cookies and network position, to read data from another origin. The victim is the user, and the data is the user's. The browser is the only party that sees both the page and the cookies, so the browser is where the check lives.

Anyone not using a browser simply skips it. curl, Postman, a Python script, a mobile app, or the attacker's own server can send any request with any Origin header they like and read every byte of the response. They do not have the user's cookies, but they have whatever credentials they own, and CORS will not stop them.

An architecture diagram. Inside the user's browser, a page from evil.example sends a request through the browser's CORS check to api.example.com, and the check hides the response from the page. Outside any browser, a curl script sends the same request straight to the API and reads the whole response, because nothing checks CORS there.user's browserevil.example pageasks for /v1/meCORS checkhides the responseapi.example.comanswers bothrequestcurl or a scriptno check: reads it all
CORS lives in the browser, between a page and the responses it asks for. Anything outside the browser talks to your API directly, so the API must authenticate every call on its own.

What actually protects the server

  • Authentication and authorization on every endpoint. Check who the caller is and whether they may do this, on the server, every time. An API with no auth is public, whatever its CORS headers say.
  • CSRF defenses for state changes. Simple cross-origin requests still arrive with cookies. Use SameSite cookies, check the Origin header, or require a custom header or token, as the CSRF lesson explains.
  • Rate limits, input validation, and abuse detection. These are server concerns that CORS has nothing to do with.

The Origin header is still useful to the server, just not as identity. Browsers set it and scripts cannot change it, so a server can reject unexpected origins for CSRF purposes. But non-browser clients can send any Origin they like, so it never replaces authentication.

Choosing a setup for your app

Often the best CORS configuration is none at all. Here are the realistic options for a web app that talks to its own API.

Decision How should the web app reach its API?

Same origin through a proxy

Serve the API under https://app.example.com/api, routed by the CDN, a reverse proxy, or a backend-for-frontend.

The browser sees one origin; the proxy forwards to the API servers.

Pros

  • No CORS headers, no preflights, no CORS bugs.
  • Cookies are first-party and SameSite=Lax or Strict just works.
  • Saves a round trip on every non-simple call.

Cons

  • Needs routing at the edge or a BFF layer.
  • One more hop if the proxy is far from the API.

Same-site subdomain with a CORS allowlist

App at app.example.com, API at api.example.com.

The API echoes allowed origins, sends Allow-Credentials: true and Vary: Origin.

Pros

  • Teams deploy the API and app separately.
  • Same-site cookies still work.

Cons

  • Preflights on most calls, cached per URL for at most 2 hours in Chrome.
  • An allowlist to keep correct and tested.

Public API with a wildcard

Access-Control-Allow-Origin: *, no credentials.

Callers authenticate with an API key or token in a header.

Pros

  • Any site can integrate.
  • Responses can be cached by CDNs for everyone.

Cons

  • Only for data that is safe for any page to read.
  • Tokens in headers mean preflights, and the keys live in browser code.

What we pick: For your own app and your own API, route through the same origin; it removes a class of bugs and a round trip. Use an exact allowlist when separate subdomains are unavoidable, and the wildcard only for truly public, non-credentialed data.

Check your understanding · Exercise 8

An API sets Access-Control-Allow-Origin: https://app.example.com and has no other checks. What does that protect against? Pick one.

Using CORS in a system design interview

CORS rarely is the interview question. It shows up inside a bigger design: a dashboard calling several APIs, an embeddable widget, a file uploader that talks to object storage, or an app split across subdomains. Bringing it up at the right moment, briefly and correctly, is a strong signal.

When to bring it up

  • When you draw the API boundary. Say where the API lives relative to the app. "I'll serve it under /api on the same origin through the CDN, so no CORS and no preflights" takes ten seconds and removes a whole topic.
  • When the client calls a third party directly, such as uploading to a storage bucket with a pre-signed URL. Note that the bucket needs a CORS rule for your origin, the PUT method, and the Content-Type header.
  • When you design an embeddable widget or a public API. Explain the choice between a wildcard with token auth and an iframe served from your own origin.
  • When latency comes up. Preflights add a round trip per URL; a same-origin route or fewer custom headers removes them.

How deep to go by level

LevelWhat a strong answer shows
JuniorKnows the same-origin policy blocks reading, that the server opts in with Access-Control-Allow-Origin, and that the browser enforces it.
MidExplains simple versus preflighted requests, why * fails with cookies, and how to debug a CORS error from the Network panel.
SeniorAvoids CORS by design with a same-origin route or BFF, knows preflight caching limits and Vary: Origin, and separates CORS from CSRF and from server auth.
StaffSets an org-wide policy: an exact allowlist in shared middleware, tests for bypass origins, subdomain hygiene, and a plan for third-party and private-network calls.

A debugging answer that works

"Users see a CORS error" is a favorite follow-up. Walk through these questions in order, out loud:

  1. Was there a preflight, and did it return 2xx with the method and headers allowed?
  2. Does the real response carry Access-Control-Allow-Origin with the exact origin, also on errors?
  3. If cookies are involved, is Allow-Credentials: true there and the origin not *?
  4. Does it fail for some users only, pointing to a missing Vary: Origin behind a CDN?

Think first

Your design uploads large videos straight from the browser to a storage bucket on another origin, using pre-signed URLs. What CORS setup does the bucket need, and what goes wrong if you forget it?

Summary

  • The same-origin policy blocks reading responses from other origins, not sending requests to them. An origin is scheme, host, and port.
  • CORS is a server opting in with response headers, and the browser enforcing them. A CORS error often means the server already handled the request.
  • Requests with GET, HEAD, or POST, only safelisted headers, and a form-style content type are simple. Everything else, including JSON bodies and Authorization, gets an OPTIONS preflight.
  • Preflight answers are cached per origin and URL, capped at 2 hours in Chrome and 24 hours in Firefox.
  • With credentials, Access-Control-Allow-Origin must be the exact origin plus Access-Control-Allow-Credentials: true; echo only allowlisted origins and always send Vary: Origin.
  • Never reflect any origin, never trust null, avoid loose pattern matches, and do not trust every subdomain.
  • CORS protects users in browsers, not your server: authenticate every call and use CSRF defenses for state changes.
  • The simplest CORS setup is none: serve the API on the same origin through a proxy or BFF.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Users see "blocked by CORS policy" in the console, but your API logs show the request returned 200. How is that possible?

Question 2

Why does the browser send an OPTIONS request before some calls, and how would you get rid of the extra latency?

Question 3

Why can't you use Access-Control-Allow-Origin: * with cookies, and what do you do instead?

Question 4

A teammate says the API is secure because CORS only allows our domain. What do you tell them?

Question 5

Review this CORS check: allow the origin if it ends with "example.com", with credentials. What is wrong?

Practice it

These design problems lean on cors.

References

Read next