Writing a Content Security Policy for Wasm

This page answers one task: a page with a Content Security Policy refuses to compile WebAssembly, or you are adding a CSP to a page that uses WebAssembly — write a policy that allows the modules you ship and nothing more.

Prerequisites

  • [ ] Control over the page’s response headers (or a <meta http-equiv> tag as a fallback).
  • [ ] A list of where your .wasm files and scripts are served from.
  • [ ] A browser with the console open; CSP violations are reported there.

Why CSP and WebAssembly collide

A Content Security Policy restricts what a page may load and execute. Its most important directive, script-src, lists the sources of script the page trusts, and by default it forbids turning strings into code: eval, new Function and similar are blocked unless the policy includes 'unsafe-eval'. That rule exists because string-to-code is the mechanism most injection attacks rely on.

Compiling WebAssembly also turns bytes into executable code at runtime. Browsers originally treated it the same way as eval, so a page with a strict CSP could not call WebAssembly.compile or instantiateStreaming without adding 'unsafe-eval' — which also re-enabled JavaScript eval and undid much of the policy’s protection. The 'wasm-unsafe-eval' source keyword fixes that. It permits WebAssembly compilation and instantiation while leaving JavaScript eval blocked, so a page can run modules without loosening its defence against script injection.

What each script-src keyword allows 'unsafe-eval' allows both JavaScript eval and WebAssembly compilation. 'wasm-unsafe-eval' allows only WebAssembly compilation. With neither, both are blocked. neither keyword eval and new Function blocked WebAssembly.compile blocked instantiateStreaming blocked no Wasm at all 'wasm-unsafe-eval' eval and new Function blocked WebAssembly.compile allowed instantiateStreaming allowed Wasm without eval 'unsafe-eval' eval and new Function allowed WebAssembly.compile allowed injection defence weakened avoid

Step 1 — see the failure

With a strict policy and no Wasm keyword, the module fails before it runs:

Content-Security-Policy: default-src 'self'; script-src 'self'
Uncaught (in promise) CompileError: WebAssembly.instantiateStreaming(): Refused to compile or instantiate WebAssembly module because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: "script-src 'self'".

The message mentions 'unsafe-eval' for historical reasons; the right fix is the narrower keyword.

Step 2 — add ‘wasm-unsafe-eval’ to script-src

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'wasm-unsafe-eval';
  connect-src 'self';
  worker-src 'self';
  object-src 'none';
  base-uri 'none'

Each line has a reason in a Wasm app. script-src gets the new keyword. connect-src must allow the origin the .wasm file is fetched from, because fetch — which instantiateStreaming uses — is governed by connect-src, not script-src. worker-src must allow the scripts of any workers that run modules, which is where heavy Wasm usually lives.

Set the header from your server or edge configuration. For nginx:

add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' https://cdn.example.com; worker-src 'self'; object-src 'none'; base-uri 'none'" always;

Step 3 — allow the module’s origin for fetch

If modules are served from a CDN, the fetch must be allowed explicitly:

connect-src 'self' https://cdn.example.com

A blocked fetch produces a different error from a blocked compile — Refused to connect to 'https://cdn.example.com/app.wasm' because it violates the following Content Security Policy directive: "connect-src 'self'" — and the fix is in connect-src. Keep the two failure modes apart: one is about where bytes come from, the other about whether bytes may become code.

Which CSP directive governs each step of loading a module Loading a module passes through two CSP checks. Fetching the .wasm bytes is governed by connect-src; compiling them into code is governed by script-src and requires 'wasm-unsafe-eval'. A worker running the module is additionally governed by worker-src. new Worker(url) worker-src fetch(app.wasm) connect-src compile bytes script-src 'wasm-unsafe-eval' instantiate + run allowed once compiled

Step 4 — test with report-only first

Changing a CSP on a live site can break things you did not anticipate — third-party widgets, analytics, inline event handlers. Deploy the new policy in report-only mode first, which logs violations without enforcing them:

Content-Security-Policy-Report-Only:
  default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self';
  report-to csp-endpoint
Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"

Collect reports for a few days of real traffic, fix what they reveal, then switch the header name to Content-Security-Policy. A short endpoint that stores the JSON reports is enough; the important fields are blockedURL, effectiveDirective and sourceFile.

Step 5 — check workers and Emscripten specifics

Workers get their CSP from their own response headers, not from the page, when they are loaded from a URL. A worker script served without a CSP header has no policy at all; one served with a strict CSP needs 'wasm-unsafe-eval' too. Serve worker scripts with the same policy as the page, or deliberately with a policy suited to what the worker does.

Some toolchains need more than the Wasm keyword. Older Emscripten builds used new Function in parts of their glue — dynamic call wrappers, EM_ASM helpers — which 'wasm-unsafe-eval' does not cover. Current Emscripten releases avoid this with -sDYNAMIC_EXECUTION=0, which removes every use of string-to-code from the glue. wasm-bindgen’s output uses no eval at all. If a violation names eval rather than Wasm compilation, the glue is the source, not the module.

Designing the policy around the module, not just for it

Adding one keyword makes the module load; designing the policy well makes it protective. A WebAssembly module is code that runs with whatever capabilities its imports grant, so the policy’s job is to make sure only the modules you intend can arrive and compile. That comes down to keeping three sets as small as possible.

The first is where code comes from. script-src should list your own origin and, if scripts come from a CDN, that CDN’s exact origin — not a wildcard such as https: or *.cdn.example.com that any tenant of a shared CDN could satisfy. Prefer hashes or nonces for inline scripts, and avoid 'unsafe-inline' entirely; an injected inline script is the most direct way for an attacker to compile a module of their own.

The second is where module bytes come from. Because modules are fetched, connect-src is effectively the allow-list for Wasm binaries as well as for API calls. Keep it to the API origins and the asset origin that serves .wasm files. If modules come from a general-purpose CDN, add integrity checks so a file swapped on that CDN cannot run.

The third is where workers come from. Heavy Wasm runs in workers, and a worker started from a blob: URL can run arbitrary code that bypasses the page’s script-source list. Avoid blob: in worker-src unless a library genuinely requires it, and if it does, look for a configuration that loads the worker from a real URL instead — many Wasm libraries that create blob workers by default accept a worker URL option.

Review the policy whenever a dependency that ships Wasm is added or upgraded. Libraries change how they load their binaries — inline base64, blob workers, a new CDN path — and the policy is the place where those changes become visible, as a report, before they become an incident.

Expected output

With the policy in place, the module loads and the console is clean. A quick check from the console confirms the CSP behaves as intended — Wasm compiles, eval does not:

await WebAssembly.compile(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]));   // resolves: empty module
eval("1 + 1");                                                              // EvalError: refused by CSP

Gotchas

  • Adding 'unsafe-eval' to make it work. It does, and it re-enables JavaScript eval. Use 'wasm-unsafe-eval'.
  • The page works, the worker does not. The worker script has its own CSP. Set the header on worker responses too.
  • connect-src blocks the CDN. The module is fetched from another origin not listed in connect-src.
  • A <meta> CSP that ignores some directives. report-to, frame-ancestors and sandbox do not work in meta tags. Use a real header where possible.

Performance note

A CSP has no measurable cost on module loading or execution; the check happens once, before compilation. The one indirect effect is positive: -sDYNAMIC_EXECUTION=0 in Emscripten, adopted to satisfy a strict policy, also removes runtime-generated wrapper functions and made one build’s glue 3 KB smaller.

Glue size before and after removing dynamic execution Emscripten JavaScript glue for the same program with default settings and with DYNAMIC_EXECUTION=0, which removes string-to-code helpers so the page can use a strict CSP. minified glue (KB) default glue 47.2 KB -sDYNAMIC_EXECUTION=0 44.1 KB

Frequently Asked Questions

Do all browsers support ‘wasm-unsafe-eval’? Current Chrome, Edge, Firefox and Safari all do. Very old browsers ignore the unknown keyword, which with a strict policy means Wasm is blocked there — a reason to keep a non-Wasm fallback if you support them.

Does the keyword let attackers run arbitrary Wasm? It lets the page compile Wasm, but the bytes still have to arrive through an allowed connect-src origin or from script the policy already trusts. Combine it with tight connect-src and integrity checks; see verifying Wasm integrity before instantiation.

Does a CSP slow anything down? No measurable amount for Wasm. The check is a lookup before compilation, not work proportional to the module.

Is a nonce or hash useful for Wasm? Not for the module itself; CSP hashes and nonces apply to scripts. Use them for the scripts that load modules.

What about browser extensions? Manifest V3 extensions have a fixed CSP for extension pages that permits 'wasm-unsafe-eval' when declared in the manifest’s content_security_policy field.

← Back to Browser Sandbox & Security Boundaries