~/learn / web-fundamentals / same-origin-and-cors

Same-Origin Policy and CORS

The browser rule that keeps websites from reading each other's data, what it does and does not cover, exactly how CORS and preflights relax it, how postMessage crosses origins safely, and the misconfigurations that break it.

intermediateupdated

You are logged into your bank in one tab and visit a random blog in another. The blog’s JavaScript can send requests to your bank, and your browser will even attach your bank cookies. So why can the blog not read your balance? The same-origin policy. It is the browser’s most important security boundary, and understanding precisely what it does and does not stop is the key to a whole family of bugs.

What an origin is

An origin is the triple of scheme, host, and port. Two URLs share an origin only if all three match.

Compared with https://app.example.com Same origin? Why
https://app.example.com/settings Yes Only the path differs
http://app.example.com No Different scheme
https://api.example.com No Different host
https://app.example.com:8443 No Different port

Note that api.example.com and app.example.com are different origins even though they are the same company. “Same site” (used by cookies) is broader than “same origin” (used by the policy), which is a distinction worth keeping straight: SameSite cookies and the same-origin policy are different mechanisms with different scopes.

What the policy actually covers

The same-origin policy restricts reading across origins, and it governs more than fetch:

  • Reading the response body of a cross-origin fetch or XMLHttpRequest (blocked unless CORS allows it).
  • Reading into another origin’s DOM (an iframe from another origin is opaque to your scripts).
  • Reading another origin’s cookies, localStorage, and IndexedDB (each origin has its own).

Crucially, it mostly does not restrict sending:

  • A page can send requests to other origins: forms submit anywhere, <img> and <script> load from anywhere, fetch fires anywhere.
  • A page cannot read the cross-origin response unless that origin opts in.

That split is the root of two facts you already know: CSRF works because the request is sent (and cookies attach), while cross-site data theft does not, because the response cannot be read. SameSite cookies close much of the sending gap that CSRF relied on.

Crossing origins safely with postMessage

When two windows of different origins genuinely need to talk (an embedded widget, an OAuth popup), window.postMessage is the sanctioned channel. The security rule is twofold and both halves are routinely botched: the sender should specify a target origin rather than *, and the receiver must check event.origin against an allowlist before trusting the data. A handler that acts on any message from any origin is a cross-origin injection point, and it has led to real account takeovers.

CORS: opting out on purpose

Modern apps often need legitimate cross-origin reads: a frontend on app.example.com calling an API on api.example.com. Cross-Origin Resource Sharing lets the server declare which origins may read its responses. The browser sends an Origin header; the server answers with permission headers:

GET /v1/profile HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Cookie: session=...

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

For anything beyond a “simple” request, the browser first sends a preflight OPTIONS asking permission. A request is simple only if it uses GET, HEAD, or POST, carries no custom headers, and uses one of a short list of content types (text/plain, application/x-www-form-urlencoded, multipart/form-data). This is why requiring application/json and a custom header on an API forces a preflight that a cross-site HTML form cannot satisfy, which doubles as a CSRF defense.

Header Meaning
Access-Control-Allow-Origin Which single origin may read the response
Access-Control-Allow-Credentials Whether cookies and auth may be included
Access-Control-Allow-Methods and -Headers What the preflight approved
Vary: Origin Tells caches the response differs by origin

How CORS goes wrong

The dangerous combination is allowing an untrusted origin together with credentials, because then any site the victim visits can read their authenticated data from the API.

Misconfiguration Why it is dangerous
Reflecting any Origin with Allow-Credentials: true Every website can read logged-in users’ data
Allowing Origin: null Sandboxed iframes and file:// send null, so an attacker can produce it
Weak pattern matching (“ends with example.com”) attackerexample.com and example.com.evil.test match
Trusting all subdomains One XSS on any subdomain becomes API data theft
Missing Vary: Origin A cache serves one origin’s permissive response to another

Browsers refuse Access-Control-Allow-Origin: * together with credentials, so a server that wants credentialed cross-origin access must name a specific origin. Insecure servers therefore reflect whatever Origin the request carried, and that reflection is precisely what a tester probes for.

Testing CORS in a lab

Send a request with a foreign origin and read the response headers:

curl -si https://api.lab.example/v1/profile \
  -H 'Origin: https://evil.example' \
  -H 'Cookie: session=YOUR_LAB_SESSION' | grep -i access-control

If the response echoes https://evil.example with credentials allowed, the API is readable from any site. PortSwigger’s CORS labs cover each variant against a legal target.

Field notes: testing CORS for real

The bug is almost always the server reflecting or mis-matching the Origin. Send a request with a crafted Origin and read the response headers:

curl -s -I https://api.example.com/account -H "Origin: https://evil.com" | grep -i access-control

What each response means:

Response to Origin: https://evil.com Verdict
Access-Control-Allow-Origin: https://evil.com + Allow-Credentials: true Critical. Any site can read authenticated responses.
Reflects Origin: null when you send null Exploitable from a sandboxed iframe or data: document
Allows https://evil.com.example.com or https://example.com.evil.com Weak suffix/prefix match, bypassable
Access-Control-Allow-Origin: * with no credentials Low; no cookies ride along, so only public data

The high-impact case is reflected origin plus credentials: you host a page on evil.com, it fetches the victim API with credentials: "include", and because the server echoed your origin and allowed credentials, your script reads the victim’s private response and exfiltrates it. The null origin trick works because sandboxed iframes, redirects, and local files send Origin: null, which developers wrongly allowlist for “local testing.”

Field notes: when SOP does not save you

  • postMessage with no origin check. A receiver that does not verify event.origin accepts messages from any window, so a malicious opener or iframe can drive it. And a sender that posts to targetOrigin: "*" leaks the message to whatever page occupies the frame. Grep the JS for addEventListener("message" and check the first line of the handler.
  • JSONP predates CORS and ignores it entirely: a <script src> callback endpoint hands any site the data, and is also a CSP whitelist bypass.
  • DNS rebinding sidesteps SOP at the network layer by making the victim’s browser treat your server and an internal one as the same origin over time. See SSRF.
  • crossorigin leaks through subresource error events and cache timing can reveal cross-origin facts even when the body is unreadable.

Test CORS and messaging only against origins you own or are authorized to assess.

Takeaway

The same-origin policy is the browser’s load-bearing boundary. CORS and postMessage punch deliberate, narrow holes in it. Every hole should name exact, trusted origins and verify them, because “reflect whatever asked” is the same as no boundary at all.