The same origin policy is the rule that stops one website from reading the contents of another. Open a bank tab and a random tab in the same browser, and the random tab cannot read a byte of the bank’s pages. For the response body, that holds. The class of attacks called cross site leaks, usually written XS-Leaks, works around it from the side. A malicious page cannot read the bank’s response, but it can watch how the browser reacts to that response, and the reaction often depends on the secret.
The same origin policy hides the response, not the reaction
Here is the gap the attack lives in. The same origin policy is built to stop reading across origins. It says nothing about observing. When your page embeds a resource from another site, the browser still does real work: it sends the request with your cookies attached, gets a response, and decides whether to fire a load event or an error, how many frames to render, how long it took, whether to redirect. Each is a behavior the attacker sees from their own page, depending on data they are forbidden to read. So they never ask what the response said. They ask a question with a visible side effect, like did this image load or fail, and pick it so the answer maps to a secret.
What you can observe without reading
Several browser behaviors leak across origins. None hands over the response; all depend on it.
Error events: did it load or fail
Point an <img>, <script>, or <iframe> at a cross origin URL and the browser fires an onload handler if the resource loaded and an onerror handler if it did not. You cannot see the bytes, but you can see which handler ran. If a server returns a normal page for one query and an HTTP error for another, that load versus error split is a one bit answer. The xsleaks.dev wiki documents this as Error Events.
Frame counting with window.length
A few window properties stay readable across origins by design, and the most useful is window.length, the number of subframes a page rendered. Open a cross origin page with window.open or embed it in an iframe, read .length, and you learn how many frames it drew. If a page renders one frame per search result, the count tells you how many results a query returned. The wiki calls this Frame Counting, and a real Facebook leak used it to read private facts about a logged in user.
Timing, cache, status codes, redirects
- Timing. A bigger response, or one that needs more processing, takes longer to load. Time an embed for a noisy read on response size or server work.
- Cache probing. Load a resource and time it. A fast load means it was already in the victim’s cache, which tells you the victim visited the page that cached it. It is a close cousin of web cache deception.
- Status codes and redirects. A 200 and a 404 behave differently for an embedded resource, and a redirect can change the frame count. Each difference is another oracle.
A worked example of cross site leaks
Put the pieces together. Say victim.example has a search page at /orders?q= that only shows results to the logged in account, and you want a secret order number shaped like ORD- followed by digits. The victim visits the attacker page while logged in elsewhere, so the embedded request carries the victim’s cookies and the server answers as the victim. The attacker embeds a query, reads the frame count to see whether it matched, and walks the secret digit by digit.
// Attacker page on evil.example. The victim is logged in to
// victim.example in another tab. We never read a response body.
function matches(query) {
return new Promise(resolve => {
const f = document.createElement('iframe');
// The victim's cookie rides along, so the server answers
// as the logged in user.
f.src = 'https://victim.example/orders?q=' + query;
f.onload = () => {
// window.length is readable across origins.
// One rendered frame per matching result.
resolve(f.contentWindow.length > 0);
f.remove();
};
document.body.appendChild(f);
});
}
let known = 'ORD-';
for (let pos = 0; pos < 12; pos++) {
for (const c of '0123456789') {
if (await matches(known + c)) { known += c; break; }
}
}
// known now holds the secret, recovered one character at a time.
Ten guesses per position, twelve positions, and the attacker rebuilds a secret they were never allowed to read. The same shape works with onload versus onerror if the server errors on an empty result. This family is called XS-Search, and it scales: binary split a range to cut the probes, and run many in parallel.
The attacker never reads the answer. They ask the browser a question with a visible side effect, and the side effect is the answer.
Why being logged in is the whole point
None of this works against a stranger. The attack needs the victim’s session to ride along on the embedded request, so the server returns the personalized response whose behavior leaks the secret. That dependency on an ambient session is exactly what CSRF abuses, and it points at the best defense: stop the cookie from going out on a cross site request at all.
Defenses that actually close the gap
Defense is layered, hitting the problem at three points: the cookie, the request, and the window.
SameSite cookies
A cookie marked SameSite=Lax is not sent on cross site subrequests like images, scripts, and iframes, and SameSite=Strict withholds it on cross site top level navigation too. With the session cookie gone, the embedded request is anonymous, the server returns the generic page, and the behavior no longer tracks the victim’s secret. Browsers now default to Lax, which already removes a slice of the easy oracles.
Fetch Metadata and the Sec-Fetch-Site header
Browsers attach a set of Sec-Fetch-* headers describing where a request came from. The key one is Sec-Fetch-Site, which tells the server whether the request was same origin, same site, or cross site. The server can read it and reject cross site requests it has no reason to serve, returning a 403. Google’s Fetch Metadata guidance calls this a Resource Isolation Policy: it stops the request at the door, so there is no behavior left to observe.
COOP, COEP, CORP and cross origin isolation
The cross origin headers were built as a direct answer to XS-Leaks and Spectre. Each cuts a specific channel:
Cross-Origin-Opener-Policy(COOP) severs the window reference between your page and one you opened. Set tosame-origin, it puts the new document in its own context group, so an attacker cannot readwindow.lengthor other window properties off it. That kills frame counting and the named window leaks.Cross-Origin-Resource-Policy(CORP) lets a server declare that its resource may not be embedded by other origins at all. The browser blocks the load, so the load versus error oracle never gets a clean signal.Cross-Origin-Embedder-Policy(COEP) requires every resource a page loads to opt in through CORP or CORS. On its own it does little, but paired with COOP it turns on the strongest mode.
Set COOP to same-origin and COEP to require-corp and the page becomes cross origin isolated: the browser gives the document its own process and shuts off the cross origin window relationships and shared state the leaks depend on. It is the most complete protection and the most work to adopt, since every third party resource has to play along.
The assumption that breaks
Every web app trusts one quiet idea: that the same origin policy keeps a secret in a response safe from any page that cannot read it. The policy is real and it does guard the bytes. What it never promised to guard is the browser’s behavior around those bytes, visible from anywhere. The secret was never only in the response. It was also in the load event, the frame count, the timing, the cache, the status code, the redirect. An attacker who cannot open the envelope can still weigh it, shake it, and time how long it takes to arrive.
That gap, between what a control is documented to do and what an attacker can infer around its edges, is the kind of thing you find by testing the assumption, not trusting the label. UnboundCompute is an early stage autonomous security researcher for web apps and APIs that probes those edges and proves what it finds with evidence. Read more about that approach on our about page.
Frequently asked questions
What is a cross site leak?
A cross site leak, or XS-Leak, is a side channel attack where a malicious page infers small pieces of cross origin information about a logged in victim. It never reads the protected response. Instead it watches behaviors the same origin policy does not hide, such as whether an embedded resource loads or errors, how many frames a page rendered, how long a request took, or whether a resource was already cached.
Why does the same origin policy not stop XS-Leaks?
The same origin policy is built to stop one site from reading another site’s response body, and it does that well. It says nothing about observing how the browser reacts to that response. An attacker cannot read the bytes, but they can watch the load or error event, the frame count, or the timing, and each of those reactions can depend on the secret. The leak lives in the gap between reading and observing.
How do SameSite cookies help against cross site leaks?
Most XS-Leaks need the victim’s session to ride along on the embedded request so the server returns the personalized response. A cookie set to SameSite=Lax is not sent on cross site subrequests like images and iframes, and SameSite=Strict withholds it even more broadly. With the session cookie gone, the request is anonymous and the response no longer tracks the victim’s secret, which removes a large slice of the easy oracles.
What do COOP, COEP, and CORP do?
These cross origin headers each cut a specific leak channel. Cross-Origin-Opener-Policy severs the window reference to a page you opened, so an attacker cannot read properties like window.length off it and frame counting fails. Cross-Origin-Resource-Policy lets a server forbid other origins from embedding its resource at all. Cross-Origin-Embedder-Policy paired with COOP makes a page cross origin isolated, which puts it in its own process and shuts off the shared state the leaks rely on.
