~/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.
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
fetchorXMLHttpRequest(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,fetchfires 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
postMessagewith no origin check. A receiver that does not verifyevent.originaccepts messages from any window, so a malicious opener or iframe can drive it. And a sender that posts totargetOrigin: "*"leaks the message to whatever page occupies the frame. Grep the JS foraddEventListener("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.
crossoriginleaks 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.