🛡️ Security Headers Beyond CSP 101: COOP, COEP, and the Isolation You're Not Using
Every security blog post about HTTP headers follows the same script: set Content-Security-Policy, set X-Frame-Options, maybe mention Strict-Transport-Security if the author is feeling ambitious, then wrap up with a "ship it, you're secure now" energy that hasn't been true since about 2018.
Here's the thing nobody tells you: CSP stops script injection. It does approximately nothing about a whole other class of attack that lives one layer below the DOM — the browser's process and memory model. That's where Cross-Origin-Opener-Policy (COOP), Cross-Origin-Embedder-Policy (COEP), and Cross-Origin-Resource-Policy (CORP) live, and if you've never configured them, you're not alone — most teams haven't. Which is exactly the problem, because these three headers are the difference between your tab being isolated in its own process and your tab sharing a process (and therefore a side-channel-attackable memory space) with whatever else the user has open.
The attack CSP can't see
Picture this incident, which is a composite of a few real ones I've helped clean up over the years: a marketing site embeds a third-party "live chat" widget in an iframe. The widget vendor gets compromised — supply chain, same story every time. The malicious script inside that iframe can't read your page's DOM directly (same-origin policy handles that), but it can still hold a reference to window.opener if your page opened it via window.open(), and it can attempt Spectre-style timing attacks against data sitting in the same renderer process, because by default, unrelated cross-origin documents are still allowed to share a process in most browsers unless you tell them not to.
CSP's script-src and frame-src directives never enter the picture here. They're about what content is allowed to load and execute, not about process isolation between origins. Two completely different threat models, two completely different headers.
COOP: stop leaking your window reference
Cross-Origin-Opener-Policy cuts the tie between your page and any window that opened it (or that it opened), when origins differ:
Cross-Origin-Opener-Policy: same-origin
With this set, if evil.com does window.open('https://yourapp.com/dashboard'), that popup gets put in its own browsing context group. evil.com's script can no longer poke at newWindow.opener, can't manipulate navigation, and — this is the part people miss — the browser now puts your page in a separate OS process from evil.com, which is your actual defense against Spectre-class speculative execution attacks reading cross-origin memory.
I've seen this bite login flows specifically. OAuth popups, payment provider redirects, "open in new tab" support widgets — anywhere your app is opened by or opens a window to a domain you don't fully trust, COOP is the header standing between "isolated tab" and "shared process with attacker-controlled code." Start with same-origin-allow-popups if you have legacy window.open() flows that still need to talk back to the opener; tighten to same-origin once you've audited them.
COEP: the one that actually breaks things (test first)
Cross-Origin-Embedder-Policy is the stricter sibling — it requires every cross-origin resource your page loads (images, scripts, iframes, fonts) to explicitly opt in via CORS or Cross-Origin-Resource-Policy, or it gets blocked:
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
This is the one that will light up your error console the first time you deploy it, because that random CDN image you've been hotlinking for three years almost certainly doesn't send Cross-Origin-Resource-Policy or CORS headers. COEP + COOP together are what unlock crossOriginIsolated — required if you're using SharedArrayBuffer, high-resolution timers, or WASM threads. If none of that applies to you, you may not need require-corp at all; COOP alone still buys you the opener isolation without the embedding headache. Don't cargo-cult COEP onto a site that doesn't need cross-origin isolation — audit what you actually embed first.
// Express example — apply narrowly, verify nothing breaks before rolling out globally
app.use((req, res, next) => {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
// Only add COEP if you actually need cross-origin isolation:
// res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
next();
});
At Cubet, we rolled COOP out to a client-facing dashboard well before touching COEP — mostly because the dashboard embedded a handful of third-party analytics and support widgets, and require-corp would have blocked every one of them until we chased down CORS headers from vendors who, unsurprisingly, took their time responding to "please add a header" tickets. COOP shipped in an afternoon. COEP took three weeks of vendor emails.
The practical rollout order
Cross-Origin-Resource-Policy: same-siteon your own static assets first — low blast radius, tells browsers your images/fonts aren't meant to be embedded cross-origin.- COOP
same-origin-allow-popups, watch for broken OAuth/payment popups, then tighten tosame-origin. - COEP last, and only if you need
crossOriginIsolated— audit every third-party embed before flippingrequire-corp, because it fails closed and will silently blank out iframes and images that don't comply.
None of this replaces CSP — it sits alongside it. CSP is your perimeter against injected script; COOP/COEP/CORP are your isolation boundary against the browser's own process-sharing defaults. Ship both, in the right order, and actually read the report-only mode output before you enforce anything in production.
If you've fought your own COEP rollout horror story — or found a vendor iframe that just refuses to cooperate — I'd genuinely like to hear it. Find me on GitHub, Twitter/X, or LinkedIn.