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.

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

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.

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