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.
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.
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.com | Same origin? | Why |
|---|---|---|
https://app.example.com/orders?page=2 | Yes | Only the path and query differ |
http://app.example.com | No | Different scheme |
https://api.example.com | No | Different host, even though the site is the same |
https://app.example.com:8443 | No | Different port |
https://example.com | No | The 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.
postMessage instead.- Allowed without CORS: showing images, running scripts, applying stylesheets, playing media, and embedding iframes; submitting forms; following links; sending
fetchrequests that count as simple. - Blocked without CORS: reading a
fetchorXMLHttpRequestresponse 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
documentor another window's DOM. That needspostMessage, 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.
- The browser compares origins. The page's origin and the URL's origin differ, so the request runs in CORS mode.
fetchuses modecorsby default. - The browser adds an
Originheader. It names the page's origin:Origin: https://app.example.com. Your script cannot set or fake this header; the browser owns it. - The browser decides the request is simple (the next chapter covers the rules) and sends it right away, with no extra round trip.
- The server handles the request like any other and adds
Access-Control-Allow-Originto the response if it wants this origin to read it. - The browser runs the CORS check. If the response header is
*or exactly matches theOriginit sent, the check passes. Otherwise it fails. - The page gets the result. On a pass, the
fetchpromise resolves with a normalResponse. On a fail, it rejects with aTypeError, and the console logs a CORS error. The script cannot tell a CORS failure apart from a network failure, on purpose.
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.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"}]
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.
- The method is
GET,HEAD, orPOST.PUT,PATCH, andDELETEalways need a preflight. - Every header the script sets is CORS-safelisted. That means
Accept,Accept-Language,Content-Language,Content-Type(with a limited value, below), andRangewith a single simple byte range. Headers the browser adds itself, likeUser-AgentorAccept-Encoding, do not count. Any custom header, such asAuthorization,X-Requested-With, orX-CSRF-Token, makes it preflighted. Content-Type, if set, is one of three values:application/x-www-form-urlencoded,multipart/form-data, ortext/plain.application/jsonis not on the list.- 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.
- No upload progress listener and no streaming body. Adding an event listener on
XMLHttpRequest.upload, or sending aReadableStreamas the body, forces a preflight.
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:
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
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.
PUT is never sent.Details that trip people up
- Preflights carry no cookies or
Authorization. The browser never sends credentials on anOPTIONSpreflight. A server that checks login on every route,OPTIONSincluded, answers401, and the browser treats that as a failed preflight. AnswerOPTIONSbefore 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
301or302, the browser fails it. Point clients at the final URL, including the trailing slash your framework expects. - Proxies and CDNs must pass
OPTIONSthrough. A gateway that rejects unknown methods, or caches anOPTIONSanswer for one origin and replays it to another, breaks preflights in ways that look random.
The request
The server policy
| Preflight | yes: PUT is not simple |
|---|---|
| Real request reaches the server | no |
| JavaScript can read the response | no: 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.
| Header | Sent on | Answers | Typical value |
|---|---|---|---|
Access-Control-Allow-Origin | Preflight and real response | Which origin may read this? | https://app.example.com or * |
Access-Control-Allow-Methods | Preflight | Which methods may the real request use? | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers | Preflight | Which request headers may the script set? | Authorization, Content-Type |
Access-Control-Allow-Credentials | Preflight and real response | May the request carry cookies, and may the page read the result? | true (or leave it out) |
Access-Control-Expose-Headers | Real response | Which extra response headers may the script read? | ETag, Link, X-Request-Id |
Access-Control-Max-Age | Preflight | How 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.
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".
// Sends the api.example.com cookies along with the request.
const res = await fetch("https://api.example.com/v1/me", {
credentials: "include",
});
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.
Access-Control-Allow-Credentials: truemust be present. Without it, the browser blocks the read.Access-Control-Allow-Originmust be the exact origin, never*. A wildcard with credentials fails. The same goes for*inAccess-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.
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;
}
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.
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")acceptshttps://notexample.com, which anyone can buy. A suffix check must include the dot:.example.com.origin.startsWith("https://app.example.com")acceptshttps://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, sohttps://evilexample.commatches, 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: Originwith a reflected origin: random failures behind a CDN, as chapter 6 showed. - Allowing
http://origins for anhttpsAPI: 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.
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.
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()andtoDataURL()throw aSecurityError. Load it withcrossorigin="anonymous"from a server that sendsAccess-Control-Allow-Origin, and the canvas stays readable. Photo editors and WebGL textures depend on this. - Fonts. Web fonts loaded with
@font-facefrom another origin always use CORS. A font CDN withoutAccess-Control-Allow-Originmeans 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.onerroras just "Script error.", with no message or stack. Error tracking tools ask you to addcrossorigin="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 samecrossoriginvalue 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.
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
SameSitecookies, check theOriginheader, 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?
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
/apion 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
PUTmethod, and theContent-Typeheader. - 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
| Level | What a strong answer shows |
|---|---|
| Junior | Knows the same-origin policy blocks reading, that the server opts in with Access-Control-Allow-Origin, and that the browser enforces it. |
| Mid | Explains simple versus preflighted requests, why * fails with cookies, and how to debug a CORS error from the Network panel. |
| Senior | Avoids 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. |
| Staff | Sets 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:
- Was there a preflight, and did it return
2xxwith the method and headers allowed? - Does the real response carry
Access-Control-Allow-Originwith the exact origin, also on errors? - If cookies are involved, is
Allow-Credentials: truethere and the origin not*? - Does it fail for some users only, pointing to a missing
Vary: Originbehind 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?
The browser sends a PUT with a Content-Type header to the bucket's origin, so it preflights. The bucket's CORS rule must allow your exact app origin, the PUT method, and the Content-Type header (plus any checksum headers you send). If the client reads the ETag from each part of a multipart upload, the rule must expose ETag too.
Without the rule, the preflight fails and the upload never starts; the console shows a CORS error. Without the exposed ETag, single uploads work but multipart uploads cannot complete, because the client cannot collect the part tags. No credentials are needed: the pre-signed URL carries the authorization.
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, orPOST, only safelisted headers, and a form-style content type are simple. Everything else, including JSON bodies andAuthorization, gets anOPTIONSpreflight. - Preflight answers are cached per origin and URL, capped at 2 hours in Chrome and 24 hours in Firefox.
- With credentials,
Access-Control-Allow-Originmust be the exact origin plusAccess-Control-Allow-Credentials: true; echo only allowlisted origins and always sendVary: 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?
That is normal for CORS. The server handled the request and answered, but the response lacked the right headers, so the browser hid it from the page. CORS checks reading, not sending, for simple requests.
I would check the response in the Network panel: is Access-Control-Allow-Origin present and exactly the page's origin? If the request uses cookies, is Access-Control-Allow-Credentials: true there and the origin not *? If it fails only for some users, I would look for a missing Vary: Origin behind a CDN.
Question 2
Why does the browser send an OPTIONS request before some calls, and how would you get rid of the extra latency?
That is a preflight. Any request a plain HTML form could not send, such as PUT, a JSON body, or an Authorization header, is checked first, so servers that predate CORS never receive requests they did not expect.
Best fix: put the API on the same origin under /api, routed by the CDN or a BFF, so there is no CORS at all. If it must stay cross-origin, set Access-Control-Max-Age (Chrome caps it at 2 hours, Firefox at 24 hours), avoid needless custom headers, and remember the cache is per URL. I would not switch to text/plain bodies to dodge preflights, because that throws away a CSRF protection.
Question 3
Why can't you use Access-Control-Allow-Origin: * with cookies, and what do you do instead?
The wildcard means "any site may read this". With cookies, that would let any site read each visitor's private data, so browsers reject * whenever the request includes credentials.
Instead the server keeps an exact allowlist, compares the request's Origin, echoes it only on a match, adds Access-Control-Allow-Credentials: true, and always sends Vary: Origin so caches keep per-origin copies.
Question 4
A teammate says the API is secure because CORS only allows our domain. What do you tell them?
CORS is enforced by browsers to protect users, not by the server. curl, scripts, and other servers ignore it and can read any response. So every endpoint still needs authentication and authorization.
Also, simple cross-origin requests are sent with cookies even when CORS fails, so state-changing endpoints need CSRF protection: SameSite cookies, an Origin check, or a token.
Question 5
Review this CORS check: allow the origin if it ends with "example.com", with credentials. What is wrong?
https://notexample.com passes, and anyone can register it. The suffix must include the dot, and the scheme must be https. Parse with new URL, compare the hostname to an exact set, or check it equals the base or ends with .example.com.
Even a correct subdomain match trusts every subdomain, so one XSS bug or taken-over DNS record anywhere exposes the API. For a credentialed API I would list the few origins that really call it, never allow null, and add tests for bypass origins.
Practice it
These design problems lean on cors.
References
- Cross-Origin Resource Sharing (CORS) MDN's full guide: simple requests, preflights, credentials.
- Same-origin policy What counts as an origin and what is blocked.
- Fetch Standard: CORS protocol The spec that defines every header and check.
- Access-Control-Max-Age Default and browser caps for the preflight cache.
- Cross-Origin Resource Sharing (web.dev) A short practical introduction.
- PortSwigger Web Security Academy: CORS Real misconfigurations: reflected origins, null, and parsing bugs.
Read next
- Cross-site request forgery (CSRF)An attack where another site makes the user's own browser send a request to your app, with the user's cookies attached, so the app thinks the user asked for it. SameSite cookies, tokens, and origin checks stop it.
- Content Security PolicyA response header that tells the browser which scripts, frames, and connections a page may use, so an injected script is blocked even when an XSS bug slips through.
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- Backend for frontend (BFF)A small server, usually owned by the frontend team, that gathers data from many backend services and returns exactly what one screen of one kind of client needs.
- REST for frontendsA way to shape an HTTP API around resources and a few standard verbs, so the frontend can fetch, cache, retry, and handle errors in the same predictable way everywhere.