CORS misconfiguration: when Allow-Origin becomes a data leak
Wildcard and reflected-origin CORS policies can hand authenticated API responses to any site a victim visits. The risky patterns and the correct configs.
Cross-Origin Resource Sharing (CORS) is one of those browser features that is easy to get subtly, dangerously wrong. A single overly permissive response header can turn an authenticated API into a public one — not public to everyone directly, but public to any website your logged-in users happen to visit. This article explains the same-origin policy CORS relaxes, the exact patterns that leak data, and the configuration that is actually safe.
The same-origin policy, and what CORS relaxes
By default, browsers enforce the same-origin policy. JavaScript running on
https://a.example can send a request to https://b.example, but it cannot
read the response unless the two origins match on scheme, host, and port. This is the rule that
stops a random site you visit from quietly reading your webmail or your bank's API on your behalf while
your session cookie rides along.
CORS is the sanctioned way to poke a hole in that rule. When a browser makes a cross-origin request, the
server can answer with an Access-Control-Allow-Origin header naming which origin is allowed to
read the response. If the requesting origin is on the list, the browser hands the body to the calling
script. If not, the browser blocks the read. Crucially, CORS is enforced by the browser, not the
server — the server merely advertises who it trusts. Get that advertisement wrong and you have
told every browser to release your data to the wrong people.
The dangerous patterns
Reflecting the request Origin (the big one)
The most common serious mistake is to read the incoming Origin request header and echo it
straight back into Access-Control-Allow-Origin. It looks like an allowlist, but it accepts
everything — whatever origin asks is the origin that gets approved. A response like this:
Access-Control-Allow-Origin: https://whoever-asked.example
Access-Control-Allow-Credentials: true
is effectively "allow any origin, with credentials." The second header is what makes it critical. With
Access-Control-Allow-Credentials: true, the browser will include the victim's cookies (or HTTP
auth) on the cross-origin request and release the response to the calling script. So a malicious
page a logged-in victim visits can call your authenticated API, the request carries the victim's session,
and the attacker's JavaScript reads the private response — profile data, tokens, internal records,
whatever that endpoint returns. No XSS on your site required; the victim only has to be logged in and load
the wrong page.
The wildcard-plus-credentials trap people misremember
Here is the detail writers get wrong. The CORS spec forbids combining a literal wildcard with credentials:
if Access-Control-Allow-Origin: * is sent, the browser will refuse to expose
a credentialed response. So a literal * is not itself the credential-leak vector — the browser
protects you there. The vector is reflecting a specific origin, because a reflected origin is a
concrete value (https://attacker.example, not *), and the spec happily allows a
concrete origin together with credentials. Developers reach for reflection precisely to "work around" the
wildcard-plus-credentials restriction, and in doing so build the exact hole the restriction existed to
prevent. A literal * is still worth reviewing — it exposes responses that don't need
credentials — but it is a different, lower-severity issue than reflection.
Trusting the null origin
Some setups add null to their allowlist to make local files or sandboxed frames work. The
problem is that Origin: null is not a trusted party — it is sent by sandboxed iframes, some
redirects, and documents loaded from data: URLs, all of which an attacker can arrange. Pairing
Access-Control-Allow-Origin: null with credentials is the reflection bug in a different hat:
an attacker-controlled context can present null and be trusted.
Sloppy origin matching
When teams do build a real check, they often match the origin with a substring, prefix, suffix, or a loose regular expression — and those are easy to fool:
-
Suffix / "ends with" checks: allowing any origin ending in
yourdomain.comalso allowsnot-yourdomain.com— a domain an attacker can register that simply ends with the same characters. -
Prefix / "starts with" checks: allowing anything starting with
https://yourdomain.comalso allowshttps://yourdomain.com.evil.exampleorhttps://yourdomain.company-attacker.example. -
Unescaped dots in regexes: a pattern like
^https://app.yourdomain.com$treats.as "any character," sohttps://appxyourdomain.comcan match. -
Substring "contains" checks: allowing any origin that contains
yourdomain.commatches almost anything an attacker registers.
Even a well-meant allowlist becomes an allow-any if the comparison is fuzzy. Overly broad lists have the same effect: trusting an entire subdomain space, or leaving a long-dead partner origin in the list, quietly re-opens the hole.
What safe CORS looks like
The correct approach is boring, which is the point:
-
Keep an explicit server-side allowlist of exact origins — full scheme, host, and port,
compared with exact string equality. Not a regex, not a suffix test. If
Originis on the list, echo that exact value back; if not, send no CORS headers at all and let the browser block the read. -
Never reflect an arbitrary origin, and never add
nullto the allowlist. -
Send
Access-Control-Allow-Credentials: trueonly on the specific endpoints that genuinely need cookies, and only alongside a single exact origin. If an endpoint doesn't need credentials, don't enable them — the blast radius of any mistake shrinks dramatically. -
Add
Vary: Originwhen the allowed origin depends on the request, so caches and CDNs don't serve one origin's approval to another. -
Scope it to what's needed — don't broaden
Access-Control-Allow-MethodsorAccess-Control-Allow-Headersbeyond what the endpoint actually accepts.
A safe response for a credentialed endpoint therefore looks like a single, exact origin drawn from your allowlist — for example:
Access-Control-Allow-Origin: https://app.yourdomain.example
Access-Control-Allow-Credentials: true
Vary: Origin
The distinction that matters: the value is one origin you deliberately trust, chosen by an exact-match lookup — not whatever the caller sent.
How Cyber Report checks this
Cyber Report runs an external, non-intrusive CORS audit: it sends cross-origin preflight and
simple requests with test Origin values and inspects the response headers your server returns.
It flags the reflected-origin case (echoing an arbitrary Origin back) as critical, a trusted
null origin as high, and wildcard-with-credentials as a distinct lower-severity finding — and
it reports the credential interaction so you can tell a data-leaking policy from a merely loose one.
See what we scan, or run a scan on your own domain to see where you stand. CORS lives alongside the rest of your response headers, so it's worth reading how those fit together in HTTP security headers explained; if your allowlist trusts subdomains, make sure none of them are vulnerable to subdomain takeover first.