Cross-Origin Isolation with COOP and COEP
Some powerful browser features — SharedArrayBuffer, performance.now() at high resolution, and Atomics.wait() — are locked behind cross-origin isolation. A page becomes cross-origin isolated only when it sets two response headers together: Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy. Turning them on frequently surfaces new CORS and CORP requirements on subresources that loaded fine before, which is where this overlaps with everyday cross-origin debugging.
This page is part of CORS & Related Security Header Interactions, which explains how CORS sits alongside the other cross-origin security mechanisms.
The Symptom This Resolves
You enable the isolation headers and a previously-working cross-origin resource suddenly fails, or SharedArrayBuffer is still undefined:
A cross-origin resource at https://cdn.example.com/lib.js was blocked because
Cross-Origin-Embedder-Policy: require-corp was set and the resource did not supply
a Cross-Origin-Resource-Policy header or a valid CORS response.
Or, checking in the console:
self.crossOriginIsolated // false — isolation not achieved
typeof SharedArrayBuffer // "undefined"
Root Cause
After the Spectre side-channel attacks, browsers restricted shared memory and precise timers, because together they let a malicious page infer bytes across the origin boundary. Those features are now gated behind the crossOriginIsolated state, defined in the HTML standard, which is true only when the document sends both Cross-Origin-Opener-Policy: same-origin (severing the opener relationship with cross-origin windows) and Cross-Origin-Embedder-Policy: require-corp (requiring every embedded cross-origin resource to explicitly opt in). Under require-corp, a cross-origin subresource must either send Cross-Origin-Resource-Policy: cross-origin or be fetched in CORS mode with a valid Access-Control-Allow-Origin — otherwise it is blocked, even if it loaded before isolation was enabled.
COOP is the half of the pair that teams underestimate. Cross-Origin-Opener-Policy: same-origin moves the document into its own browsing context group, so any window it opens with window.open() — and any window that opened it — loses the scripting handle in both directions: window.opener reads as null unless the other document is same-origin and carries a compatible policy. That severance is the point. While a cross-origin window can hold a live reference into your window, the browser cannot promise that no attacker-controlled code shares your process, and it will not hand you shared memory. The cost lands on OAuth popups, payment sheets, and any postMessage handshake with a cross-origin child window: they stop working the moment the header ships, and they fail silently rather than throwing. Inventory every window.open() call site before you deploy, and move cross-origin handshakes to a full-page redirect or to a same-origin relay document that talks to the third party on your behalf.
Both headers have to arrive on the same document response before the browser flips the switch — isolation is a logical AND, not a score:
Prerequisite State
- The document is served over HTTPS.
- You control the response headers of the main document.
- You can either add CORP headers to cross-origin subresources or load them with CORS (
crossoriginattribute).
Step-by-Step
Step 1 — Set the opener policy
add_header Cross-Origin-Opener-Policy "same-origin" always;
Step 2 — Set the embedder policy
add_header Cross-Origin-Embedder-Policy "require-corp" always;
Step 3 — Opt in each cross-origin subresource
For a resource you control on another origin, add a CORP header:
# On the CDN / asset origin
add_header Cross-Origin-Resource-Policy "cross-origin" always;
For a resource loaded via a tag, request it in CORS mode so a valid Access-Control-Allow-Origin satisfies require-corp:
<script src="https://cdn.example.com/lib.js" crossorigin="anonymous"></script>
<img src="https://cdn.example.com/logo.png" crossorigin="anonymous" alt="Logo">
Third-party assets are the hard case, because you cannot add a header to an origin you do not run. Three options remain: proxy the asset through your own origin so its response headers become yours to set, drop it from isolated documents, or fall back to Cross-Origin-Embedder-Policy: credentialless. Credentialless mode still isolates the document, but instead of demanding an opt-in from every subresource it sends no-CORS cross-origin requests stripped of cookies and client certificates — so a public CDN asset loads unchanged while a credentialed one comes back empty. It is the pragmatic setting for pages that embed public third-party media, while require-corp stays the stricter choice for asset pipelines you control end to end.
Four combinations cover nearly every subresource outcome you will meet once the header goes live:
Step 4 — Confirm isolation
console.log(self.crossOriginIsolated); // should log true
Verification
Staging the Rollout with Report-Only
Flipping require-corp on a busy page in one step is how teams take down a checkout flow. Both isolation headers have report-only twins — Cross-Origin-Opener-Policy-Report-Only and Cross-Origin-Embedder-Policy-Report-Only — which run the full evaluation, report every resource that would be blocked, and block nothing:
# Stage 1 — evaluate without enforcing
add_header Cross-Origin-Opener-Policy-Report-Only 'same-origin; report-to="coi"' always;
add_header Cross-Origin-Embedder-Policy-Report-Only 'require-corp; report-to="coi"' always;
add_header Reporting-Endpoints 'coi="https://app.example.com/_reports/coi"' always;
Each report names the blocked URL and the policy that would have rejected it, which turns the migration into a finite worklist instead of a hunt through the console. The rollout has three phases, and it is worth keeping them separated in time:
Watch one asymmetry while staging: crossOriginIsolated stays false for the whole report-only phase, so every code path that feature-detects SharedArrayBuffer keeps taking its fallback branch and cannot be validated yet. Nor is a quiet report stream proof that the enforcing switch will be quiet — a resource fetched conditionally, lazily, or only for signed-in users reports only once that path actually executes, so hold the observation window open across a full traffic cycle.
Framework and Host Gotchas
The isolation headers must land on the document response, which is exactly the response many toolchains do not let you touch by default:
- Dev servers. Vite’s
server.headersand the webpack dev server’sheadersoption are separate from your production configuration, so isolation commonly works in one environment and fails silently in the other. Set both, and checkself.crossOriginIsolatedin each. - Static hosts. Netlify’s and Cloudflare Pages’
_headersfiles, and Firebase Hosting’sheadersarray, need an entry scoped to the HTML paths. Applyingrequire-corpto your own asset paths does nothing useful: COEP is a policy of the embedding document, whileCross-Origin-Resource-Policyis the asset-side answer to it. - Express with Helmet. Helmet sets
Cross-Origin-Opener-Policy: same-originby default but leaves the embedder policy off, which produces a half-configured document that never isolates and only breaks popups. Set both explicitly rather than trusting the defaults. - Service workers. A worker that serves navigations from the cache must replay the isolation headers on its synthetic
Response; otherwise the first navigation isolates and the cached second one does not, andSharedArrayBufferdisappears mid-session. - Preview and staging domains. Isolation is evaluated per document, not per site, so a preview deployment that omits the headers reports
falsewhile production reportstrue. Treat the headers as part of the deploy artifact, not as an environment override.
Security Boundary Note
require-corp is a hardening feature — do not weaken subresource policies just to silence a block. Prefer adding Cross-Origin-Resource-Policy: cross-origin only to assets that are genuinely safe to embed anywhere (public fonts, images, scripts). Do not set Cross-Origin-Resource-Policy: cross-origin on responses that contain user- or tenant-scoped data, and do not switch a credentialed API to crossorigin mode with a wildcard origin — the wildcard-plus-credentials prohibition still applies inside an isolated context.
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
| Setting only COEP or only COOP | crossOriginIsolated stays false; features stay locked |
Set both headers on the document |
| Cross-origin subresource with neither CORP nor CORS | Resource blocked under require-corp; page breaks |
Add Cross-Origin-Resource-Policy or load with crossorigin |
Adding Cross-Origin-Resource-Policy: cross-origin to private data |
Any page can embed sensitive responses | Use it only on genuinely public assets |
FAQ
Why do I need cross-origin isolation for SharedArrayBuffer?
SharedArrayBuffer and high-resolution timers were restricted after the Spectre side-channel attacks, because precise timers plus shared memory let a malicious page infer data across origins. Browsers now gate them behind the crossOriginIsolated state, which is only true when the document sets both Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Without isolation, SharedArrayBuffer is unavailable and timers are coarsened.
How does COEP interact with CORS?
Cross-Origin-Embedder-Policy: require-corp forces every cross-origin subresource to opt in, either by sending Cross-Origin-Resource-Policy: cross-origin or by loading in CORS mode with a valid Access-Control-Allow-Origin. A cross-origin image or script sending neither is blocked once require-corp is set. Enabling isolation can therefore surface CORS/CORP requirements on resources that previously had none.
What is the difference between CORP and CORS?
Cross-Origin-Resource-Policy (CORP) is a response header by which a resource declares who may embed it: same-origin, same-site, or cross-origin. CORS governs whether a script may read a response. Under COEP require-corp, a subresource satisfies the embedder either by sending a permissive CORP header or by being fetched with CORS. CORP is a simpler opt-in for resources safe to embed anywhere.
Why did window.opener become null after I enabled COOP?
That is Cross-Origin-Opener-Policy: same-origin working as designed. It places the document in its own browsing context group, which severs the scripting handle in both directions between your window and any cross-origin window on either side of a window.open() call. Nothing throws — window.opener is simply null, and a postMessage handshake to a popup silently never arrives. Rework the flow as a full-page redirect that returns to your origin with the result, or route it through a same-origin relay document that talks to the third party itself.
When should I use COEP credentialless instead of require-corp?
Use credentialless when the page must embed third-party assets whose response headers you cannot change, which is the usual blocker for a media-heavy page. In that mode the browser still isolates the document but issues no-CORS cross-origin requests without cookies or client certificates, so public assets load unchanged and credentialed ones return empty rather than blocking the load. Use require-corp when every subresource is first-party or can be made to send Cross-Origin-Resource-Policy, because it fails loudly at build-out time rather than quietly returning an unauthenticated variant of a resource.