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-Policyfrom 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:
access-control-allow-origin: *makes the resource readable cross-origin for non-credentialed requests.access-control-expose-headersmakes 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:
-
Document headers
Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp
-
Cross-origin scripts and modules
- add
crossorigin="anonymous"where relevant - return
Access-Control-Allow-Origin
- add
-
Workers
- verify worker script responses are CORS-approved
-
WASM, JSON, and fetch-loaded assets
- confirm
mode: "cors"behavior is expected - handle preflight if needed
- confirm
-
Fonts and media
- return CORS headers from CDN/static server
-
Readable response headers
- expose headers your frontend actually uses with
Access-Control-Expose-Headers
- expose headers your frontend actually uses with
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-Policyif 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: