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:

The two headers that gate cross-origin isolation Two header cards feed a single junction that requires both of them on the same document response. The junction leads to the isolated state, which in turn unlocks SharedArrayBuffer, Atomics.wait and high-resolution timers. Cross-Origin-Opener-Policy same-origin Cross-Origin-Embedder-Policy require-corp Both headers on the same document response either one alone leaves the state false and changes nothing visible self.crossOriginIsolated === true SharedArrayBuffer Atomics.wait() high-resolution timers the gated APIs stay available only while the state holds COOP alone breaks popups; COEP alone breaks subresources; neither alone unlocks anything

Prerequisite State

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:

Subresource opt-in outcomes under require-corp A four-row matrix. Each row pairs the way the browser fetches a cross-origin subresource with the headers its response carries, and shows whether the embedder policy allows the load. Only a CORP header or a matching CORS response satisfies require-corp. How the browser fetches it What the response sends Result under require-corp Plain tag load, no crossorigin attribute neither CORP nor CORS headers BLOCKED ERR_BLOCKED_BY_RESPONSE Plain tag load, no crossorigin attribute Cross-Origin-Resource-Policy: cross-origin LOADS embedder satisfied by CORP crossorigin="anonymous", fetched in CORS mode Access-Control-Allow-Origin: https://app.example.com LOADS embedder satisfied by CORS crossorigin="anonymous", fetched in CORS mode no Access-Control-Allow-Origin BLOCKED CORS mode, no matching header Silence is not consent: a subresource that sends neither answer is blocked, however well it loaded before

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:

Three-phase rollout of the isolation headers A timeline with three phases along one axis. The first phase ships report-only headers and blocks nothing, the second fixes each reported subresource, and the third swaps in the enforcing headers so the isolated state finally turns true. 1 — Observe ship both Report-Only headers and a report sink nothing is blocked yet 2 — Fix the list CORP on assets you own, crossorigin or a proxy on the ones you do not 3 — Enforce swap in the enforcing pair on the document the state turns true traffic keeps flowing one asset at a time isolation switches on Report-only tells you which subresources will break before any of them actually do

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:

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.