Configuring COOP/COEP Headers for SharedArrayBuffer

This guide shows the exact response headers, per server, that make SharedArrayBuffer — and therefore WebAssembly threads — available, and how to confirm they took effect.

SharedArrayBuffer exposes a high-resolution timing side channel that Spectre-class attacks exploit, so browsers only define it when the document is cross-origin isolated. Isolation is an opt-in promise: the page swears it shares no browsing context with cross-origin pages and embeds no subresource that hasn’t consented. You make that promise with two response headers on the document — Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp — and you keep it by tagging every subresource with a Cross-Origin-Resource-Policy (or a valid CORS grant). Get all three right and self.crossOriginIsolated flips to true, unlocking shared memory, Atomics, and WebAssembly.Memory({ shared: true }).

The two headers play distinct roles. Cross-Origin-Opener-Policy: same-origin is about window relationships: it severs the window.opener link to any page that isn’t same-origin, so a popup or opener from another origin can no longer reach into your page’s globals. Cross-Origin-Embedder-Policy: require-corp is about subresources: it refuses to load any image, script, font, worker, or .wasm that doesn’t explicitly state it may be embedded by your origin. Only when both hold does the browser consider the page sealed enough to hand back the dangerous primitive. Miss either one and crossOriginIsolated stays false, SharedArrayBuffer is undefined, and a threaded WebAssembly build throws deep inside its loader rather than where the misconfiguration actually is. That indirection is why the verification step at the end matters as much as the configuration itself.

The gate that unlocks shared memory Cross-Origin-Opener-Policy same-origin severs the window from cross-origin openers. Cross-Origin-Embedder-Policy require-corp forces every subresource to opt in. Only when both are present does crossOriginIsolated become true and the SharedArrayBuffer constructor become usable. Cross-Origin-Opener-Policy same-origin cuts the link to cross-origin openers Cross-Origin-Embedder-Policy require-corp every subresource must opt in both, on the same document response not the .wasm response crossOriginIsolated === true SharedArrayBuffer constructible Wasm threads and Atomics.wait usable either one missing SharedArrayBuffer is not defined — with no hint about which header Isolation is a property of the document, so it must be granted before the page loads — no script can opt in afterwards. Check it in the console with self.crossOriginIsolated before debugging anything else.

Prerequisites

  • [ ] A server whose response headers you control: Vite 5+, Express 4+, Caddy 2.7+, nginx 1.25+, or Cloudflare Pages/Workers
  • [ ] A Wasm module built with threads (-C target-feature=+atomics,+bulk-memory,+mutable-globals for Rust, -pthread for Emscripten)
  • [ ] curl for header inspection
  • [ ] A browser with DevTools (self.crossOriginIsolated is the ground truth)
  • [ ] Local dev over http://localhost is fine; a real hostname requires https

Procedure

The configuration is the same shape on every server — set two headers on the document, tag subresources, verify — so the steps below are server-agnostic and the per-server snippets in step 1 are interchangeable. Work top to bottom the first time; once it works, the only step you revisit is verification.

  1. Set both isolation headers on the document. Cross-Origin-Opener-Policy: same-origin detaches the page from any opener; Cross-Origin-Embedder-Policy: require-corp makes every subresource opt in. Apply the configuration for your server below.

    Vite — the dev and preview servers take a headers map:

    // vite.config.js
    import { defineConfig } from "vite";
    
    export default defineConfig({
      server: {
        headers: {
          "Cross-Origin-Opener-Policy": "same-origin",
          "Cross-Origin-Embedder-Policy": "require-corp",
        },
      },
      preview: {
        headers: {
          "Cross-Origin-Opener-Policy": "same-origin",
          "Cross-Origin-Embedder-Policy": "require-corp",
        },
      },
    });

    Express — set the headers in middleware ahead of the static handler, and pin Content-Type: application/wasm so streaming compilation works:

    const express = require("express");
    const app = express();
    
    app.use((req, res, next) => {
      res.set("Cross-Origin-Opener-Policy", "same-origin");
      res.set("Cross-Origin-Embedder-Policy", "require-corp");
      next();
    });
    
    app.use(
      express.static("dist", {
        setHeaders(res, filePath) {
          if (filePath.endsWith(".wasm")) {
            res.set("Content-Type", "application/wasm");
            res.set("Cross-Origin-Resource-Policy", "same-origin");
          }
        },
      })
    );
    
    app.listen(3000);

    Caddy — a header block applies to all responses in the site:

    # Caddyfile
    localhost:8080 {
        root * ./dist
        file_server
        header {
            Cross-Origin-Opener-Policy "same-origin"
            Cross-Origin-Embedder-Policy "require-corp"
            Cross-Origin-Resource-Policy "same-origin"
        }
    }

    nginx — use add_header … always so the headers are emitted even on error responses:

    server {
        listen 8080;
        root /srv/dist;
    
        add_header Cross-Origin-Opener-Policy "same-origin" always;
        add_header Cross-Origin-Embedder-Policy "require-corp" always;
    
        location ~ \.wasm$ {
            types { application/wasm wasm; }
            add_header Cross-Origin-Resource-Policy "same-origin" always;
        }
    }

    Cloudflare Pages / Workers — a static _headers file at the project root sets headers per path:

    # _headers
    /*
      Cross-Origin-Opener-Policy: same-origin
      Cross-Origin-Embedder-Policy: require-corp
    
    /*.wasm
      Content-Type: application/wasm
      Cross-Origin-Resource-Policy: same-origin
  2. Make subresources satisfy require-corp. Once require-corp is on the document, every fetched asset must carry Cross-Origin-Resource-Policy: same-origin (for same-origin assets) or a CORS grant plus crossorigin attribute (for cross-origin ones). Same-origin worker scripts and .wasm files need the CORP header, shown in the .wasm rules above.

  3. Handle assets you cannot add CORP to. If a third-party resource refuses to send CORP or CORS headers, switch the embedder policy to credentialless. It loads cross-origin subresources without credentials instead of requiring opt-in, and still grants isolation:

    Cross-Origin-Embedder-Policy: credentialless
  4. Construct shared memory the right way. Once isolated, create a single shared WebAssembly.Memory and hand it to every thread. The shared: true flag requires a maximum, because a shared memory’s backing buffer cannot be reallocated on growth — the engine reserves the maximum up front so all threads keep a stable view:

    const memory = new WebAssembly.Memory({ initial: 256, maximum: 4096, shared: true });
    const worker = new Worker("/worker.js", { type: "module" });
    worker.postMessage({ memory });   // structured clone shares the buffer, does not copy it
  5. Restart the server and hard-reload. Isolation is decided at document load. Restart so new headers apply, then reload with the cache disabled.

  6. Verify in the console. Read self.crossOriginIsolated; a true means the privilege is granted.

Expected output

From the command line, the document must carry both isolation headers and the .wasm must be CORP-tagged:

curl -sI http://localhost:8080/ | grep -iE 'cross-origin-(opener|embedder)-policy'
# cross-origin-opener-policy: same-origin
# cross-origin-embedder-policy: require-corp

curl -sI http://localhost:8080/app.wasm | grep -iE 'content-type|resource-policy'
# content-type: application/wasm
# cross-origin-resource-policy: same-origin

In the browser console, the runtime confirms isolation took effect:

self.crossOriginIsolated;            // true
typeof SharedArrayBuffer;            // "function"
new WebAssembly.Memory({ initial: 1, maximum: 4, shared: true }); // no throw
What require-corp does to the rest of the page Same-origin assets load unchanged. Cross-origin assets load only if they send Cross-Origin-Resource-Policy or are fetched with CORS and marked crossorigin. Third-party embeds that send neither are blocked outright, and the credentialless variant trades cookies for compatibility. same-origin your JS, CSS, .wasm loads unchanged cross-origin with CORP CDN sends cross-origin-resource-policy loads · or use crossorigin + CORS neither header analytics, fonts, map tiles blocked — silently, in most cases iframes must be isolated too or they will not render credentialless — the escape hatch Cross-Origin-Embedder-Policy: credentialless keeps isolation but sends cross-origin requests without cookies, so public assets load without the third party changing anything — and authenticated ones stop working. Audit the page's whole subresource list before turning isolation on: this is where a working site quietly loses features.

Gotchas

  • SharedArrayBuffer is not defined. The headers are missing, malformed, or were cached before they existed. Confirm self.crossOriginIsolated === true first — if it is false, no header on the .wasm file alone will help, because the policy lives on the document.

  • Third-party assets blocked by require-corp. A fonts CDN, analytics tag, or cross-origin image with no CORP/CORS support fails to load and the console logs net::ERR_BLOCKED_BY_RESPONSE.NotSameOriginAfterDefaultedToSameOriginByCoep. Switch that document to Cross-Origin-Embedder-Policy: credentialless, or self-host the asset.

  • Headers on the .wasm but not the HTML. COOP/COEP only do anything on the document response. The page that constructs the shared memory is the HTML, so that is where the policy must sit.

  • A proxy or CDN strips the headers. Reverse proxies frequently drop Cross-Origin-* headers. Re-run curl -I against the proxied port after adding any proxy in front of your origin.

  • shared: true rejected with a TypeError. Constructing new WebAssembly.Memory({ initial, shared: true }) without a maximum throws, because a shared memory must reserve its full address range up front — its buffer cannot be reallocated on growth while other threads hold views. Always supply maximum.

  • Worker fails to construct. With require-corp active, the Worker constructor fetches the worker script as a subresource. If /worker.js lacks the isolation and CORP headers, the constructor is blocked and the page never spawns its thread pool. Apply the headers site-wide.

Performance note

Cross-origin isolation has no measurable cost on its own — it is a policy flag, not extra work. The win is downstream: once SharedArrayBuffer is available, a thread pool sharing one WebAssembly.Memory sidesteps postMessage structured-clone copies entirely. Handing a 16 MiB buffer to four workers via shared memory is a pointer hand-off measured in microseconds, where cloning it four times would move 64 MiB through the heap on every dispatch. The cost you do pay is up front and one-time: a shared memory reserves its maximum address range immediately, so a generous maximum inflates the instance’s reserved (not resident) footprint. Size it to the workload — large enough to avoid running out mid-run, but not so large that you reserve gigabytes you never touch.

Isolation is worth adopting deliberately rather than reflexively. The capability it unlocks — real shared memory across workers — is the only way to run genuinely parallel Wasm, and for a compute workload that is transformative. But a page that never calls Atomics.wait and never spawns a worker pool gains nothing from isolation while paying for it in blocked third-party resources. Turn it on for the routes that need threads, not for the whole origin, and verify the subresource list on each of those routes before shipping.

Scope isolation to the routes that use threads A marketing route with third-party embeds loses features and gains nothing. An application route running a worker pool over shared memory gains parallel execution that is otherwise impossible. Serving the headers per route keeps both working. / — marketing route embeds a video, a map, an analytics tag never touches SharedArrayBuffer isolation here costs features, buys nothing serve it without the headers /editor — application route four workers over one shared memory first-party assets only isolation unlocks real parallelism serve COOP + COEP here Most hosts let you scope response headers by path — the same mechanism you already use for cache policy. Feature-detect with self.crossOriginIsolated and fall back to a single-threaded path rather than assuming.

Frequently Asked Questions

What is the difference between require-corp and credentialless? require-corp blocks any cross-origin subresource that does not explicitly opt in with a CORP or CORS header — the strictest mode. credentialless instead loads cross-origin subresources without sending credentials (cookies, client certs), so assets you don’t control still load while isolation is preserved. Both yield crossOriginIsolated === true.

Do I need https for this to work? On localhost, no — the browser treats it as a secure context, so SharedArrayBuffer and isolation work over plain http. On any other hostname you need https; without TLS the browser withholds shared memory regardless of your COOP/COEP headers.

Why is Cross-Origin-Resource-Policy needed on my own same-origin .wasm? With require-corp active, the browser requires every subresource — even same-origin ones — to declare a resource policy. Cross-Origin-Resource-Policy: same-origin is the explicit grant that lets your isolated document load its own .wasm. The deeper threading model that this unlocks lives in SharedArrayBuffer, Atomics & threading.

← Back to Local Development Server Configurations