Cross-Origin-Embedder-Policy sounds abstract until it blows up a working app.

I’ve seen this happen on teams that enabled Cross-Origin-Embedder-Policy: require-corp to unlock SharedArrayBuffer, improve isolation, or satisfy a performance-heavy feature using WebAssembly. Everything looked fine in local dev. Then production started blocking scripts, workers, fonts, and random third-party assets that had worked for years.

The root problem usually isn’t COEP by itself. It’s that COEP forces you to be honest about cross-origin resource loading. And that means CORS suddenly matters for resources your app used to “just load.”

This case study walks through a real-world migration pattern: a developer portal adding COEP, breaking embedded cross-origin assets, and fixing them with proper CORS.

The setup

We had a docs-style web app on https://docs.example.test.

The app wanted cross-origin isolation for:

  • a WebAssembly-powered code playground
  • a worker using SharedArrayBuffer
  • better guarantees around embedded cross-origin resources

So we shipped these headers on the main document:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

That was enough to opt the page into cross-origin isolation.

It also broke a bunch of stuff.

What broke immediately

The app depended on assets from https://cdn.example-assets.test:

  • JS modules
  • web workers
  • fonts
  • a WASM binary
  • some JSON config files

Before COEP, the page loaded them with regular tags and fetches:

<script type="module" src="https://cdn.example-assets.test/app-runtime.js"></script>
<link rel="stylesheet" href="https://cdn.example-assets.test/fonts.css">
const worker = new Worker("https://cdn.example-assets.test/worker.js", {
  type: "module",
});

const wasmResponse = await fetch("https://cdn.example-assets.test/engine.wasm");
const config = await fetch("https://cdn.example-assets.test/config.json").then(r => r.json());

Without COEP, browsers were pretty forgiving here. With require-corp, they stopped being forgiving.

The browser started rejecting cross-origin resources unless they were explicitly allowed through either:

  • CORS, or
  • Cross-Origin-Resource-Policy from the resource server

For resources that need to be fetched and used by the page, CORS is often the practical fix.

The first mistaken fix

The team added crossorigin to the script tag and thought they were done:

<script
  type="module"
  src="https://cdn.example-assets.test/app-runtime.js"
  crossorigin="anonymous"></script>

That changed the request mode, but it did not magically authorize the response.

The CDN was still returning no CORS headers:

HTTP/1.1 200 OK
Content-Type: application/javascript

Under COEP, that response was blocked.

This is the part developers often miss: client-side markup can request a CORS fetch, but the server still has to approve it.

Before: broken under COEP

Here’s what the asset server looked like before:

location / {
    root /var/www/assets;
}

And here’s what the frontend looked like:

<script
  type="module"
  src="https://cdn.example-assets.test/app-runtime.js"
  crossorigin="anonymous"></script>

<link
  rel="preload"
  href="https://cdn.example-assets.test/engine.wasm"
  as="fetch"
  crossorigin="anonymous">
const worker = new Worker("https://cdn.example-assets.test/worker.js", {
  type: "module",
});

const wasm = await fetch("https://cdn.example-assets.test/engine.wasm");

Typical failures:

  • module script blocked
  • worker creation failed
  • WASM fetch returned an unusable response
  • fonts silently failed or showed CORS errors in DevTools

After: the actual fix

We updated the asset server to return explicit CORS headers for public static resources:

location / {
    root /var/www/assets;
    add_header Access-Control-Allow-Origin "*" always;
}

That alone fixed a lot because these were public, non-credentialed assets.

For requests that might need methods or headers beyond a simple GET, we handled preflight too:

location /api-config/ {
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin "https://docs.example.test" always;
        add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
        add_header Access-Control-Max-Age 86400 always;
        return 204;
    }

    add_header Access-Control-Allow-Origin "https://docs.example.test" always;
    proxy_pass http://config_backend;
}

Then the frontend became explicit about CORS:

<script
  type="module"
  src="https://cdn.example-assets.test/app-runtime.js"
  crossorigin="anonymous"></script>

<link
  rel="stylesheet"
  href="https://cdn.example-assets.test/fonts.css"
  crossorigin="anonymous">
const worker = new Worker("https://cdn.example-assets.test/worker.js", {
  type: "module",
});

const wasmResponse = await fetch("https://cdn.example-assets.test/engine.wasm", {
  mode: "cors",
});

if (!wasmResponse.ok) throw new Error("WASM load failed");

const configResponse = await fetch("https://cdn.example-assets.test/api-config/playground.json", {
  mode: "cors",
});

const config = await configResponse.json();

Now the browser had what it needed:

  • the page required embeddable-safe resources because of COEP
  • the resource server explicitly allowed cross-origin access with CORS

That’s the relationship people tend to blur. COEP is the policy pressure. CORS is one of the mechanisms that satisfies it.

A useful real-world reference: GitHub’s API

When I explain this to teams, I like showing a real response everyone recognizes.

api.github.com returns:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset

That’s a solid example of a public API doing two smart things:

  1. access-control-allow-origin: * makes the resource readable cross-origin for non-credentialed requests.
  2. access-control-expose-headers makes non-simple response headers readable to JavaScript.

That second part matters more than people expect.

Before: header exists but JS can’t read it

const res = await fetch("https://api.github.com/repos/octocat/Hello-World");
console.log(res.headers.get("ETag")); // null if not exposed

If a response includes custom or non-simple headers, your frontend can’t automatically read them. The server has to expose them.

After: exposed headers are usable

Because GitHub exposes ETag, Link, and rate-limit headers, code like this works:

const res = await fetch("https://api.github.com/repos/octocat/Hello-World");

console.log("ETag:", res.headers.get("ETag"));
console.log("Next page:", res.headers.get("Link"));
console.log("Remaining:", res.headers.get("X-RateLimit-Remaining"));

For a docs portal or embed-heavy app under COEP, this becomes relevant fast. You’re often fetching APIs cross-origin and relying on metadata like pagination, cache validators, or rate limits.

Where teams usually get burned

1. Wildcard CORS plus credentials

I still see this misconfiguration constantly:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

That combination is invalid for credentialed requests. If your app sends cookies or credentials: "include", you need a specific origin:

Access-Control-Allow-Origin: https://docs.example.test
Access-Control-Allow-Credentials: true
Vary: Origin

2. Fonts are treated like an afterthought

Fonts loaded cross-origin often need CORS too, especially once you tighten isolation.

Bad:

@font-face {
  font-family: "Inter";
  src: url("https://cdn.example-assets.test/inter.woff2") format("woff2");
}

Better server response:

Access-Control-Allow-Origin: *
Content-Type: font/woff2

3. Workers fail because the script URL isn’t CORS-safe

Module workers are strict. Under COEP, a cross-origin worker script without proper CORS support is dead on arrival.

If you host workers on another origin, treat them like code, not like static decoration.

4. JSON config endpoints quietly need preflight support

The moment someone adds an Authorization header or switches methods, your “simple” request turns into a preflighted one. If OPTIONS isn’t handled correctly, the app fails before the real request even starts.

The practical migration checklist

When I do this on a real app, I check resources in this order:

  1. Document headers

    • Cross-Origin-Opener-Policy: same-origin
    • Cross-Origin-Embedder-Policy: require-corp
  2. Cross-origin scripts and modules

    • add crossorigin="anonymous" where relevant
    • return Access-Control-Allow-Origin
  3. Workers

    • verify worker script responses are CORS-approved
  4. WASM, JSON, and fetch-loaded assets

    • confirm mode: "cors" behavior is expected
    • handle preflight if needed
  5. Fonts and media

    • return CORS headers from CDN/static server
  6. Readable response headers

    • expose headers your frontend actually uses with Access-Control-Expose-Headers

A clean “after” example

Here’s a minimal Express setup for public static assets used by a COEP-protected app:

import express from "express";
import path from "path";

const app = express();

app.use((req, res, next) => {
  res.setHeader("Access-Control-Allow-Origin", "*");
  res.setHeader("Access-Control-Expose-Headers", "ETag, Content-Length");
  next();
});

app.use(express.static(path.join(process.cwd(), "public")));

app.listen(8080);

And for a credentialed config endpoint:

app.options("/secure-config", (req, res) => {
  res.setHeader("Access-Control-Allow-Origin", "https://docs.example.test");
  res.setHeader("Access-Control-Allow-Credentials", "true");
  res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
  res.setHeader("Vary", "Origin");
  res.sendStatus(204);
});

app.get("/secure-config", (req, res) => {
  res.setHeader("Access-Control-Allow-Origin", "https://docs.example.test");
  res.setHeader("Access-Control-Allow-Credentials", "true");
  res.setHeader("Vary", "Origin");
  res.json({ wasmFeature: true });
});

The lesson

COEP doesn’t randomly break your app. It exposes assumptions your app was already making about other origins.

Before COEP, you could get away with loading cross-origin assets without thinking much about who approved them. After COEP, every embedded resource has to declare itself safe for that context.

That usually means one of two things:

  • serve Cross-Origin-Resource-Policy if you control the resource and want embedder-friendly rules
  • serve proper CORS headers if the browser needs to fetch and use the resource cross-origin

If you’re working through this on a production app, start with the browser console, inventory every cross-origin dependency, and fix the server responses one class of asset at a time. That’s the boring answer, but it’s the one that works.

For header-level hardening beyond CORS and COEP, the official references are the best place to start: