Running QuickJS WebAssembly in a Secure Iframe Enclave
Background and Setup Context
Embedding QuickJS (a lightweight JavaScript engine) compiled to WebAssembly inside an isolated iframe (“soft enclave”) is a strategy to run untrusted JS code safely. In this setup, a parent page (e.g. on http://localhost:3000) hosts an <iframe> pointing to an enclave page on another origin (e.g. http://localhost:3010). The enclave page loads the QuickJS WebAssembly module (via Emscripten) and executes guest code, while the parent and iframe are isolated from each other’s data (different origin prevents cookie/localStorage access ). To maximize security, we enforce a strict Content Security Policy (CSP) and use Cross-Origin Opener/Embedder Policies (COOP/COEP) so the iframe runs in a locked-down, cross-origin isolated context. However, misconfigurations in headers or CSP can cause the WebAssembly to fail loading with errors like “CompileError: WebAssembly.instantiate()”. Below, we detail the required HTTP headers, CSP settings, and other considerations to securely and reliably get this working in modern Chromium and Firefox (2025).
Content Security Policy Considerations for WebAssembly
A properly configured Content Security Policy is critical. By default, CSP may block WebAssembly module compilation unless explicitly allowed. In particular, the script-src directive needs to permit WebAssembly execution. Modern browsers implement a specific source keyword for this: 'wasm-unsafe-eval'. If a page’s CSP does not include 'wasm-unsafe-eval' (or lacks the broader 'unsafe-eval'), then WebAssembly compilation/instantiation will be blocked . This is exactly what the error message “Refused to compile or instantiate WebAssembly module because ‘unsafe-eval’ is not an allowed source” indicates . To fix this, update the CSP of the enclave page to include either 'unsafe-eval' (which also allows JS eval() – not ideal) or the more fine-grained 'wasm-unsafe-eval' token under script-src . For example:
Content-Security-Policy: default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self'; ...
In the above, we allow scripts from self (the enclave’s own origin) and permit WASM eval. The connect-src 'self' is also important – Emscripten’s loader typically uses fetch() or XHR to load the .wasm file, so the CSP must allow network requests to fetch the module. If the .wasm binary is served from the same origin as the iframe (recommended), then connect-src 'self' covers it. Ensure other needed directives (like style-src for any styles, or frame-ancestors) are set as appropriate. Notably, if you want to restrict who can embed the enclave page, set frame-ancestors 'self' http://localhost:3000 on the enclave’s CSP. In summary, a strict CSP can be used (no inline scripts, no third-party resources), but it must allow the WASM instantiation and any required fetches. Modern Chrome and Firefox honor 'wasm-unsafe-eval' (Chrome introduced it after initially requiring full 'unsafe-eval' ), so you can maintain a strong CSP without broadly enabling JS eval.
Cross-Origin Opener Policy (COOP) and Embedder Policy (COEP)
To achieve cross-origin isolation for the iframe (needed for certain features like SharedArrayBuffer or high-resolution timers, and as a general security boundary), you should use COOP and COEP headers on both the parent and enclave pages. The parent page (top-level) should send: • Cross-Origin-Opener-Policy: same-origin – this ensures the parent is isolated from other browsing contexts, putting it in its own “browser context group” . In practice, this means the parent window will not share its context with any cross-origin popup or iframe. • Cross-Origin-Embedder-Policy: require-corp – this instructs the browser that any cross-origin resource embedded by this page must explicitly grant permission (via CORS or CORP) to be loaded  . This is what enables true isolation, as it blocks any resources that don’t opt-in. If these headers are set, the self.crossOriginIsolated property will be true in supporting browsers, indicating the page can use powerful features like WASM threads/SAB .
Crucially, the enclave iframe page itself also needs to opt in. According to Chrome’s and Firefox’s policies, the entire chain of documents must be COOP/COEP isolated for the child to get isolation features . In practice: • Serve the iframe’s HTML with Cross-Origin-Embedder-Policy: require-corp as well. (Setting COOP on the iframe response is harmless but not strictly necessary since COOP only affects top-level context groups .) • On the parent page’s <iframe> element, include the attribute allow="cross-origin-isolated". This is a Permissions Policy requirement so that the parent explicitly permits the child to be cross-origin isolated . Without this, even if the headers are set, the child frame might not be granted isolation.
With COEP: require-corp in effect, any cross-origin content the enclave tries to load must send a Cross-Origin-Resource-Policy (CORP) header or be loaded via CORS. Notably, the iframe itself is a cross-origin subresource from the parent’s perspective – thus the enclave page’s HTTP response should include Cross-Origin-Resource-Policy: cross-origin (to allow any site to embed it) or at least same-site (if localhost:3000 and localhost:3010 count as same-site)  . In this case, using Cross-Origin-Resource-Policy: cross-origin on the enclave ensures the parent’s COEP check will allow the iframe to load .
Summary of Required Headers: For a working configuration, both the parent and child pages should send the appropriate headers. For example:
// Parent page (localhost:3000) HTTP response: Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp Content-Security-Policy: default-src 'none'; script-src 'self'; frame-src http://localhost:3010; ... (plus any other needed CSP directives)
// Enclave iframe page (localhost:3010) HTTP response: Cross-Origin-Embedder-Policy: require-corp Cross-Origin-Resource-Policy: cross-origin Content-Security-Policy: default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self'; ...; frame-ancestors http://localhost:3000
And in the parent HTML, the iframe tag would be:
<iframe src="http://localhost:3010/yourEnclavePage.html" sandbox="allow-scripts allow-same-origin" allow="cross-origin-isolated"></iframe>(Note: Using the sandbox attribute is optional, but can add another layer by disabling certain features in the iframe. If you do use sandbox, include allow-scripts (so the WASM can run) and allow-same-origin (so that the code in the iframe retains its origin instead of becoming a unique opaque origin). A sandboxed iframe on a separate origin with scripts allowed is effectively very similar to a normal cross-origin iframe, with some extra restrictions on new window creation, form submission, etc.)
When configured correctly, Chrome and Firefox (2025) will allow the iframe to load and run the QuickJS WASM module and be treated as a fully isolated context. This isolation is what allows use of SharedArrayBuffer or WASM threads (if QuickJS or Emscripten needed them), since as of Chrome 92+ and contemporary Firefox, those features require cross-origin isolation (COOP+COEP)  .
One caveat: enabling COOP: same-origin on the parent will break any flows that rely on cross-window communication (e.g. OAuth popups) – in those cases you’d need workarounds or use the same-origin-allow-popups variant if available . But if the parent is a host app and the iframe is the only untrusted content, this is usually fine.
MIME Type and Loading of the Wasm Module
Another common cause of WebAssembly.instantiate() errors is the WASM file not being served correctly. Ensure the .wasm file has the correct MIME type and is accessible. The server on localhost:3010 must serve the QuickJS .wasm with Content-Type: application/wasm – many dev servers do this automatically, but some (or custom setups) might not. For example, when using a simple Python HTTP server, one must add the MIME type for “.wasm” or upgrade to a version that includes it . If the WASM is served with the wrong type or via a misconfigured route, the fetch might return an HTML error page or other content; instantiating that will throw an error about an “expected magic word” not found (the bytes 00 61 73 6d at start of a valid wasm) . In practice, if you see an error mentioning “expected magic word … found < ! D O” it means the .wasm fetch returned an HTML document (likely a 404 or redirect) . The network tab confirming an HTML response instead of wasm is a giveaway. The fix is simply to ensure the correct file path and MIME: configure the static file server to serve .wasm files and with the proper content type.
CORS/CORP for Wasm: If for some reason the WASM is loaded from a different origin than the enclave page, you’ll need to handle CORS. The simplest approach is to serve the .wasm from the same host (3010) so that no CORS is needed (it’s a same-origin fetch from the iframe’s perspective). If you must host the .wasm on another domain, you would either set Access-Control-Allow-Origin: * (and load via a crossorigin="anonymous" fetch) or have that domain send Cross-Origin-Resource-Policy: cross-origin to satisfy COEP . In a COEP environment, even same-site but cross-origin resources must opt in. For instance, if the enclave at 3010 tried to fetch a wasm from 3000, that request is cross-origin; because the enclave has COEP, the 3000 server would need to send a CORP header or the fetch must be via CORS with an ACAO header . It’s usually easier to avoid this by keeping the WASM and its loader script on the same origin as the enclave page (especially in development).
Reliable Build and Server Configuration Patterns
To implement the above, a few practical tips: • Dev Server Headers: If you use a development server (Webpack DevServer, Vite, etc.) on localhost, configure it to send the required headers. Many dev servers allow injecting headers. For example, a Vite plugin can add:
// Pseudocode for Vite/Express middleware: res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); res.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
This is exactly what some projects (e.g. StackBlitz’s WebContainer guide) recommend for local setups  . Also ensure .wasm files are served with the correct content type as noted. If using Emscripten, note that its file packaging may either inline the wasm or load it asynchronously. If you use the standard .js + .wasm output, the .js loader will attempt to fetch the .wasm by constructing a URL. Make sure the path it expects matches your setup. You can set Module.locateFile in Emscripten’s JS or use Module["memoryInitializerPrefixURL"] to control where it looks for the .wasm. In a simple scenario, placing both files in the same directory and loading the JS by a script tag works – the default fetch will be sameDirectory/yourmodule.wasm. Verify the network requests in dev tools: if the .wasm 404s or is blocked by CSP, you’ll know from console messages or network status.
• Production Server Config: Add the necessary HTTP headers at the server or CDN level. For instance, in nginx one might do:
add_header Cross-Origin-Opener-Policy same-origin always; add_header Cross-Origin-Embedder-Policy require-corp always; add_header Cross-Origin-Resource-Policy cross-origin always; add_header Content-Security-Policy "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self'; ..." always;
(The always ensures the headers are set on all responses including error pages.) If using a service like Netlify, you can specify these in a _headers file (as noted in community examples) . The key is to remember to set them for both the main app and the enclave pages. In some cases, you might host the enclave on a subdomain (e.g. enclave.yourapp.com vs app.com) – just ensure both send the COOP/COEP headers correctly.
• Optional Relaxations: If you don’t actually need SharedArrayBuffer or threads in the enclave, you technically could omit COOP/COEP entirely. The QuickJS WASM will still run without cross-origin isolation (just without SAB support). However, since your goal is a hardened enclave, you likely want the isolation anyway (and browsers may in future gate more features behind it). Another modern option (Chromium-only as of 2025) is COEP: credentialless, which allows loading cross-origin iframes without them sending CORP (it strips credentials instead) . But that is more relevant when embedding third-party iframes that you cannot control. In your scenario, since you control the enclave code, using require-corp with proper CORP headers is straightforward and supported in both Chrome and Firefox.
• QuickJS/Emscripten specifics: QuickJS itself doesn’t introduce special constraints beyond standard WASM. It runs entirely within the sandbox of WebAssembly. If you compiled QuickJS with Emscripten’s pthreads (to allow QuickJS to use multiple threads), then SharedArrayBuffer and COOP/COEP are absolutely required – but typically QuickJS is used in single-thread mode. The engine will allocate memory inside the WASM module; if your CSP accidentally disallows WebAssembly.Memory growth (e.g. via overly restrictive settings), that could be an issue, but normally 'wasm-unsafe-eval' is enough. QuickJS in WASM will happily execute JS code passed into it, but that code cannot escape the sandbox unless you explicitly provide host callbacks. So ensure you don’t inadvertently expose dangerous APIs. For instance, the QuickJS library might let you define a custom console.log or fetch for the guest – be cautious to limit what those do. The HN discussion about this library noted that if not careful, you could allow the guest code to make network calls with the user’s credentials  . Using a separate origin iframe mitigates a lot of this by default (no ambient access to cookies or DOM), and you can further whitelist network access via CSP (e.g. connect-src 'none' to block all, or only allow specific API domains) .
Examples and Community Solutions
This pattern – running an Emscripten-compiled QuickJS in an isolated iframe – is gaining traction, and a few references are worth noting: • StackBlitz WebContainers: StackBlitz runs Node.js in the browser via WebAssembly, and they use a nearly identical approach. They host the VM on a separate origin iframe, and require COOP/COEP: require-corp on both sides. Their documentation explicitly states: “both the embed and embedder have the same COOP/COEP settings… both set to require-corp. Additionally, add allow="cross-origin-isolated" on the iframe” . This confirms that our header setup is following a known-good pattern. They even provide a snippet for dev servers to set these headers (as shown above) . If your configuration matches what StackBlitz does, you’re on the right track. • Show HN: QuickJS WASM sandbox (2024): The author of quickjs-emscripten library and others discussed using iframes for isolation. They affirmed that a sandboxed or cross-domain iframe is an effective way to contain untrusted code . While they focused more on logic and API exposure, it underscores that the iframe approach (with proper sandbox flags and headers) is considered a secure practice. (Figma’s plugin system was an inspiration here; Figma isolated third-party plugins in iframes and even used a variant of SES on the code – demonstrating multiple layers of defense.) • Mozilla and Chrome docs: The MDN web docs and web.dev guides have sections on enabling cross-origin isolation. Key takeaways: to use WASM threads or SAB, “the entire frame chain must be isolated” and you need those headers on all documents . Also, CSP must be configured if you want security without breaking WASM – as of CSP Level 3, the recommended way is adding 'wasm-unsafe-eval' . Chrome shipped this to avoid forcing 'unsafe-eval' when using WASM. Firefox’s behavior is slightly more permissive with streaming compilation, but by 2025 both browsers align in requiring an explicit allow for WASM under CSP. The good news is that adding this does not allow plain JS eval, so your policy remains tight. • Working example configuration: As a concrete example, consider an open-source project that uses QuickJS in an iframe with these restrictions (if available). Even if not readily packaged, you can piece it together: A minimal demo could be an HTML page with QuickJS (WASM) that prints “Hello” inside an iframe, with all the headers set. If that works in Chrome and Firefox without errors, then layering your actual logic on it will also work. Since QuickJS-emscripten often uses dynamic module loading (multiple .js chunks and the .wasm file as seen in Simon Willison’s notes  ), be sure to serve those chunks from the enclave origin and allow them in CSP (script-src 'self' covers it if same host). The dynamic imports will be treated as normal script fetches (which COEP will allow since they’re same-origin or have CORP by virtue of the headers we set).
In summary, to reliably run QuickJS WASM in a secure iframe on Chrome/Firefox (2025): use COOP: same-origin and COEP: require-corp on all relevant pages, set Cross-Origin-Resource-Policy on the embedded content, and loosen CSP just enough to allow WASM. With these in place, the WebAssembly.instantiate() should succeed without errors, and your “soft enclave” will be both functional and strongly sandboxed  . By following these patterns – already proven in community and industry (e.g. StackBlitz, Figma) – you can achieve a robust isolation of the QuickJS engine inside the browser.
Sources: • MDN Web Docs – Content Security Policy: script-src (re: 'wasm-unsafe-eval' for WebAssembly)  • StackBlitz WebContainers Guide – Embedding cross-origin isolated iframe (COOP/COEP requirements and example)  • Chrome web.dev article – Making your website cross-origin isolated (COOP/COEP headers and CORP for resources)   • Stack Overflow – “WASM instantiate CompileError due to CSP” (confirming need for allowing unsafe-eval or wasm in CSP)  • Hacker News discussion – QuickJS WASM sandbox & iframe isolation (security considerations of same-domain vs cross-domain iframes)  • WebAssembly Spec Issue – CompileError: expected magic word (indicative of .wasm MIME/load issue)  • Stack Overflow – SharedArrayBuffer and COOP/COEP on localhost (need for these headers to get SAB working in FF/Chrome)