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
.wasmfiles 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.
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.
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 JavaScripteval. 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-srcblocks the CDN. The module is fetched from another origin not listed inconnect-src.- A
<meta>CSP that ignores some directives.report-to,frame-ancestorsandsandboxdo 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.
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.
Related
- Browser sandbox & security boundaries — the security model around modules.
- Security implications of Wasm in enterprise apps — policy decisions at organisation scale.
- Serving Wasm files with the right headers — the other headers on the same responses.
- Emitting ES modules from Emscripten — glue settings that interact with CSP.
← Back to Browser Sandbox & Security Boundaries