Workers EvalError: code generation from strings disallowed
Workers EvalError: code generation from strings disallowed
In a Cloudflare Worker, eval(), new Function() and WebAssembly.compile() do not return a value or a compile error you can catch and map — they throw EvalError: Code generation from strings disallowed for this context from inside the request, while the isolate itself starts normally. That split is what makes the failure expensive: wrangler deploy succeeds, the first request that reaches the path returns 500 Internal Server Error, and the string that explains it exists only in the runtime log. The fix is almost never "call eval differently"; it is to delete the runtime compilation step, or to fail the build before it ships.
Everything below was executed on one arm64 Linux host (kernel 6.17.0-1019-oracle, Node v26.5.1, workerd 2026-09-15, compatibility date 2026-09-01). workerd is the engine that runs Workers, so the error names, the HTTP status and the log frames quoted here are the runtime's own, not a forum recollection. Each transcript is the unedited stdout of the command printed above it. Where a behaviour is platform-side only — what wrangler tail shows on Cloudflare's network, and what an uncaught exception costs you there — the text says so and cites the documentation instead of pretending to have measured it.
TL;DR — what is blocked, where it throws, what to ship instead
- Four ways to compile a string throw the same error. Measured:
eval("6 * 7"), an indirect(0, eval)("6 * 7"),new Function("return 6 * 7")and bareFunction(...)all returnEvalError: Code generation from strings disallowed for this context. - The same security switch also disables WebAssembly compilation.
WebAssembly.compile()throwsCompileError: WebAssembly.compile(): Wasm code generation disallowed by embedder— a different error name for the same policy. - Module scope is the exception, and it is the reason your deploy looked fine. An
eval()at module scope still runs when the isolate starts; the identical call inside a request handler throws. The isolate is therefore healthy and the first request that touches the code path is not. - The client never sees the message. An uncaught case returns
500with the bodyInternal Server Error; theEvalErrorappears in the runtime log, twice, with the handler's own stack frames. node:vmis not an escape hatch. Undernodejs_compat, all three evaluation entry points answerError: The runInThisContext method is not implemented.- Three fixes ship: precompile at build time (validators, templates), catch
EvalErrorand answer501instead of letting a 500 escape, and scan the built bundle in CI. The scanner below is 30 lines and exits non-zero on a hit.
The error, exactly as the runtime produces it
The message is V8's. V8 refuses to compile a string into code when the embedder installs a code-generation callback that disallows it, and workerd installs one for the duration of a request: the same isolate that happily evaluates a string at startup refuses the same string once a request is being served. Nothing about the string changed, and nothing about your code changed — what changed is the execution context the call is made in.
In the log, the failure arrives as two records. The first is the JavaScript-level one, with your own frames at the top; the second is the engine's wrapper around it. A trace from the harness below, with the uncaught route hit twice (“raw” is the deliberately-uncaught handler, /raw/eval then /raw/fn):
Runtime log excerpt, workerd 2026-09-15 (the stack: /path/to/workerd@<hash> lines between these records are the engine's own C++ frames and are elided here).
workerd/io/worker.c++:2585: info: uncaught exception; source = Uncaught (in promise); stack = EvalError: Code generation from strings disallowed for this context
at raw (worker.js:14:29)
at Object.fetch (worker.js:4:48)
workerd/io/io-context.c++:498: info: uncaught exception; exception = workerd/jsg/_virtual_includes/iterator/workerd/jsg/value.h:1477: failed: jsg.EvalError: Code generation from strings disallowed for this context
workerd/server/server.c++:6643: error: Uncaught exception: workerd/jsg/_virtual_includes/iterator/workerd/jsg/value.h:1477: failed: remote.jsg.EvalError: Code generation from strings disallowed for this context
Read the first frame and you have the call site: at raw (worker.js:14:29) is the line that contained eval. That is the whole diagnostic, and it is why the practical first step is wrangler tail rather than a code review — on the platform the exception surfaces in the tail stream's exceptions field, the same way the sibling runtime errors in the “script will never generate a response” post do.
What the client gets instead is unremarkable: HTTP 500 with the body Internal Server Error, 21 bytes, in about a millisecond. There is no EvalError in the response, no header, no JSON. A monitoring rule that watches status codes sees “a 500 spike”; a rule that watches for the string sees nothing at all unless it reads the log.
Reproduce it on the engine instead of trusting a thread
workerd ships as a single binary (npm install workerd), so the runtime that answers the request can be the runtime you test against. The harness writes one dispatch worker with two families of route: the /raw/* routes have no try/catch and exist to show what the client and the log see, and every other route catches the error and returns its name and message as JSON so the four trigger shapes can be compared in one run.
#!/usr/bin/env bash
# Reproduce the eval restriction on workerd, the engine behind the Cloudflare Workers runtime.
# Requires: npm install workerd (binary at ./node_modules/.bin/workerd)
set -u
PORT=19951
BASE="http://127.0.0.1:$PORT"
WORKERD="./node_modules/.bin/workerd"
cat > config.capnp <<'CAPNP'
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [ (name = "main", worker = (
modules = [ (name = "worker.js", esModule = embed "worker.js") ],
compatibilityDate = "2026-09-01",
)) ],
sockets = [ (name = "http", address = "127.0.0.1:19951", http = (), service = "main") ],
);
CAPNP
cat > worker.js <<'JS'
export default {
async fetch(request) {
const path = new URL(request.url).pathname;
if (path.startsWith("/raw/")) return await raw(path); // deliberately uncaught
try {
return await handled(path);
} catch (err) {
return Response.json({ route: path, name: err.name, message: err.message });
}
},
};
async function raw(path) {
if (path === "/raw/eval") return new Response(String(eval("6 * 7")));
if (path === "/raw/fn") return new Response(String(new Function("return 6 * 7")()));
return new Response("no-route", { status: 404 });
}
async function handled(path) {
switch (path) {
case "/control":
return new Response("plain-response-ok");
case "/eval":
return new Response(String(eval("6 * 7")));
case "/eval-indirect": {
const indirect = eval;
return new Response(String(indirect("6 * 7")));
}
case "/fn":
return new Response(String(new Function("return 6 * 7")()));
case "/fn-call":
return new Response(String(Function("return 6 * 7")()));
case "/timeout-string": {
setTimeout("1 + 1", 0);
return new Response("scheduled");
}
case "/json-parse":
return Response.json(JSON.parse('{"ok":1}'));
case "/data-import": {
const mod = await import("data:text/javascript,export default 42");
return new Response(String(mod.default));
}
case "/wasm": {
const mod = await WebAssembly.compile(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]));
return new Response(String(mod));
}
case "/guard": {
try {
return new Response(String(eval("6 * 7")));
} catch (err) {
if (err instanceof EvalError) {
return new Response("feature disabled: eval is not available here\n", { status: 501 });
}
throw err;
}
}
default:
return new Response("no-route", { status: 404 });
}
}
JS
: > workerd.log
$WORKERD serve config.capnp --verbose > workerd.log 2>&1 &
SRV=$!
for i in $(seq 1 40); do
if [ "$(curl -s -o /dev/null --max-time 1 -w '%{http_code}' "$BASE/control" 2>/dev/null)" = "200" ]; then
break
fi
sleep 0.25
done
for r in control eval eval-indirect fn fn-call timeout-string json-parse data-import wasm guard raw/eval raw/fn; do
code=$(curl -sS -o body.txt -w "%{http_code}" --max-time 5 "$BASE/$r" 2>/dev/null)
printf 'GET /%-14s -> %s %s\n' "$r" "$code" "$(head -c 130 body.txt)"
done
kill -9 "$SRV" 2>/dev/null
wait "$SRV" 2>/dev/null
echo "--- JS frames from workerd.log for the two uncaught routes ---"
grep -E "^ at " workerd.log
One process, twelve requests, killed by PID at the end. The output, byte for byte:
GET /control -> 200 plain-response-ok
GET /eval -> 200 {"route":"/eval","name":"EvalError","message":"Code generation from strings disallowed for this context"}
GET /eval-indirect -> 200 {"route":"/eval-indirect","name":"EvalError","message":"Code generation from strings disallowed for this context"}
GET /fn -> 200 {"route":"/fn","name":"EvalError","message":"Code generation from strings disallowed for this context"}
GET /fn-call -> 200 {"route":"/fn-call","name":"EvalError","message":"Code generation from strings disallowed for this context"}
GET /timeout-string -> 200 {"route":"/timeout-string","name":"TypeError","message":"The \"callback\" argument must be of type function. Received type string
GET /json-parse -> 200 {"ok":1}
GET /data-import -> 200 {"route":"/data-import","name":"Error","message":"No such module \"data:text/javascript,export default 42\"."}
GET /wasm -> 200 {"route":"/wasm","name":"CompileError","message":"WebAssembly.compile(): Wasm code generation disallowed by embedder"}
GET /guard -> 501 feature disabled: eval is not available here
GET /raw/eval -> 500 Internal Server Error
GET /raw/fn -> 500 Internal Server Error
--- JS frames from workerd.log for the two uncaught routes ---
at raw (worker.js:14:29)
at Object.fetch (worker.js:4:48)
at new Function (<anonymous>)
at raw (worker.js:15:54)
at Object.fetch (worker.js:4:48)
The two /raw/* lines are the important ones for triage: the status is 500 and the body is Internal Server Error, while the handler that catches the same error returns 200 with the name and message fields filled in. Both behaviours are the runtime's; neither is a framework's error page.
Which shapes throw, and which do not
The list in the Cloudflare documentation is short and exact — the Web standards page says that, “For security reasons, the following are not allowed”, then names eval(), new Function, WebAssembly.compile, WebAssembly.compileStreaming, WebAssembly.instantiate with a buffer parameter, and WebAssembly.instantiateStreaming. Three of those are measured in the table below; the two *Streaming forms reach the same policy switch.
| Expression | Measured result |
|---|---|
eval("6 * 7") | EvalError: Code generation from strings disallowed for this context |
const e = eval; e("6 * 7") | EvalError, identical message — indirect eval is not a loophole |
new Function("return 6 * 7")() | EvalError, identical message |
Function("return 6 * 7")() | EvalError, identical message — the constructor called without new |
WebAssembly.compile(bytes) | CompileError: WebAssembly.compile(): Wasm code generation disallowed by embedder |
setTimeout("1 + 1", 0) | TypeError: The “callback” argument must be of type function. Received type string — the timer takes a Node-style function, so the string form is rejected before it could be evaluated |
import("data:text/javascript,...") | Error: No such module “data:text/javascript,export default 42”. — a different refusal for a different reason |
JSON.parse(str), atob(str) | Work normally — parsing data is not compiling code |
Two rows are traps of their own. The setTimeout failure is a TypeError from an argument check, so a log search for EvalError misses it; the data: URL is refused as an unresolvable module, which reads like a bundler problem rather than the same policy.
Note also what the caught rows hide: the handler still answers 200, so status-code monitoring is blind to a worker that degrades on every request that reaches the compiled path.
Module scope is the one place a string still compiles
This is the measurement that explains the production story. One service, one request, one isolate: a string evaluated while the module was being instantiated succeeds, and the identical call inside the handler throws.
#!/usr/bin/env bash
# One service, one request: module-scope eval vs request-scope eval.
# Requires: npm install workerd
set -u
PORT=19954
BASE="http://127.0.0.1:$PORT"
WORKERD="./node_modules/.bin/workerd"
cat > config.capnp <<'CAPNP'
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [ (name = "scope", worker = (
modules = [ (name = "scope.js", esModule = embed "scope.js") ],
compatibilityDate = "2026-09-01",
)) ],
sockets = [ (name = "http", address = "127.0.0.1:19954", http = (), service = "scope") ],
);
CAPNP
cat > scope.js <<'JS'
// Module scope runs once, when the isolate starts.
const STARTUP = eval("'startup-ok'");
export default {
fetch() {
let requestScope;
try {
requestScope = eval("'request-ok'");
} catch (err) {
requestScope = `${err.name}: ${err.message}`;
}
return Response.json({ moduleScope: STARTUP, requestScope });
},
};
JS
: > scope.log
$WORKERD serve config.capnp --verbose > scope.log 2>&1 &
SRV=$!
sleep 2
curl -sS -o scope.body -w "http_code=%{http_code}\n" --max-time 5 "$BASE/anything"
cat scope.body
echo
kill -9 "$SRV" 2>/dev/null
wait "$SRV" 2>/dev/null
The response body:
http_code=200
{"moduleScope":"startup-ok","requestScope":"EvalError: Code generation from strings disallowed for this context"}
So the failure is scoped to the request context, not to the module. That has three consequences worth stating plainly.
First, it is why the deploy passes. Bundling and upload never execute your handler bodies. An isolate that evaluates a configuration string at import time starts cleanly, serves every route that does not touch the compiled path, and fails only the routes that do — which is exactly the shape of an incident that starts with “one endpoint went down”.
Second, moving the call to module scope is not a fix. It happens to work on this engine, and the documentation makes no such promise: it states the general rule that eval() and new Function are not allowed, without a scope exception. Building on behaviour that is undocumented, unmeasured by your tests, and invisible until a request arrives is how the same class of bug returns next quarter. This note is measured on workerd 2026-09-15 only; I did not test it on Cloudflare's network.
Third, it tells you what to grep for: a build-time evaluation inside a dependency shows up in the bundle as eval or new Function, which the scan below finds without running anything.
node:vm is not the way around it
A reasonable next move is to reach for the Node module that exists to run code in a sandbox, and a reasonable amount of advice suggests vm.runInNewContext as a replacement. On workerd, with nodejs_compat active by default at this compatibility date, all three entry points refuse in the same words:
#!/usr/bin/env bash
# Is node:vm an escape hatch? Three shapes, all under nodejs_compat.
# Requires: npm install workerd
set -u
PORT=19953
BASE="http://127.0.0.1:$PORT"
WORKERD="./node_modules/.bin/workerd"
cat > config.capnp <<'CAPNP'
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [ (name = "vmws", worker = (
modules = [ (name = "vm.js", esModule = embed "vm.js") ],
compatibilityDate = "2026-09-01",
)) ],
sockets = [ (name = "http", address = "127.0.0.1:19953", http = (), service = "vmws") ],
);
CAPNP
cat > vm.js <<'JS'
import vm from "node:vm";
export default {
async fetch(request) {
const path = new URL(request.url).pathname;
try {
if (path === "/vm-context") return new Response(String(vm.runInNewContext("6 * 7")));
if (path === "/vm-this") return new Response(String(vm.runInThisContext("6 * 7")));
if (path === "/vm-script") return new Response(String(new vm.Script("6 * 7").runInNewContext({})));
return new Response("no-route", { status: 404 });
} catch (err) {
return Response.json({ route: path, name: err.name, message: err.message });
}
},
};
JS
: > vm.log
$WORKERD serve config.capnp --verbose > vm.log 2>&1 &
SRV=$!
sleep 2
for r in vm-context vm-this vm-script; do
code=$(curl -sS -o vm.body -w "%{http_code}" --max-time 5 "$BASE/$r" 2>/dev/null)
printf 'GET /%-12s -> %s %s\n' "$r" "$code" "$(head -c 200 vm.body)"
done
kill -9 "$SRV" 2>/dev/null
wait "$SRV" 2>/dev/null
Three different calls, one message:
GET /vm-context -> 200 {"route":"/vm-context","name":"Error","message":"The runInThisContext method is not implemented"}
GET /vm-this -> 200 {"route":"/vm-this","name":"Error","message":"The runInThisContext method is not implemented"}
GET /vm-script -> 200 {"route":"/vm-script","name":"Error","message":"The runInThisContext method is not implemented"}
Note that the failure is identical for runInNewContext, which the module implements by delegating to runInThisContext, and for a pre-parsed vm.Script. There is no variant that evaluates: the runtime has decided that strings do not become code at request time, and no wrapper changes the decision.
The fixes that actually ship
1. Move compilation to the build. Every runtime new Function in a dependency has a build-time twin, and the pattern of bundling the compiled artefact is the same each time: a JSON Schema validator compiled with ajv --standalone into a module you import, a template engine's precompiled render function, a regular expression built from a literal instead of from user input. The result is ordinary JavaScript in the bundle, no string reaches the compiler at request time, and the runtime stops being part of the question.
2. Turn the 500 into a 501 you can see. The error is a EvalError, so it is catchable, and a handler that identifies it can answer with a status that monitoring already distinguishes from a crash. The /guard route in the first harness does exactly that, in the form the runtime supports: catch, check err instanceof EvalError, return 501, and rethrow anything else so a real bug is not swallowed. The measured result is 501 with the body feature disabled: eval is not available here, against 500 Internal Server Error from the same call with no guard.
3. Fail the build, not the request. The cheapest version of this check does not need workerd, a browser or a deploy: scan the bundle that wrangler deploy --dry-run --outdir dist already writes, and let CI refuse a bundle that contains a token which cannot execute at request time. The scanner is coarse on purpose — it reports candidate lines, including matches inside comments and strings, because a false positive costs a reviewer ten seconds and a false negative costs an incident.
The 30-line CI scan, and its transcript
The script below writes the scanner, builds a clean fixture and a dirty one, and runs it three ways. Exit 0 means no candidate, 1 means candidates were printed, 2 means the invocation itself was wrong — the last case matters, because a scanner that answers “clean” when it was given a typo'd path is worse than no scanner.
#!/usr/bin/env bash
# Ship the scanner, then run it over a clean tree, a dirty tree, and with no arguments.
set -u
DIR=/tmp/eval-scan-demo
rm -rf "$DIR"
mkdir -p "$DIR/clean/src" "$DIR/dirty/src"
cat > "$DIR/scan_eval.mjs" <<'JS'
#!/usr/bin/env node
// Coarse scan for eval-like tokens in a built Worker bundle.
//
// Scope, stated exactly: for every .js file under the given directories, print one
// line per matching source line, truncated to 120 characters. Tokens matched:
// eval( any call or reference form
// new Function(
// Function( bare, not preceded by a dot or word character
// WebAssembly.compile( WebAssembly.instantiate(
// Matches inside comments and string literals count. This is a candidate finder
// for a code review, not a parser and not a reachability proof.
//
// Usage: node scan_eval.mjs <dir> [<dir> ...]
// Exit 0 = no candidate, 1 = candidates printed, 2 = usage error.
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
const TOKENS = [
{ name: "eval", re: /\beval\s*\(/ },
{ name: "new Function", re: /\bnew\s+Function\s*\(/ },
{ name: "Function", re: /(?<![.\w])Function\s*\(/ },
{ name: "WebAssembly", re: /\bWebAssembly\.(compile|instantiate)\s*\(/ },
];
function* walk(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const path = join(dir, entry.name);
if (entry.isDirectory()) yield* walk(path);
else if (entry.isFile() && entry.name.endsWith(".js")) yield path;
}
}
const roots = process.argv.slice(2);
if (roots.length === 0) {
console.error("usage: node scan_eval.mjs <dir> [<dir> ...]");
process.exit(2);
}
for (const root of roots) {
const st = statSync(root, { throwIfNoEntry: false });
if (!st || !st.isDirectory()) {
console.error(`not a directory: ${root}`);
process.exit(2);
}
}
let candidates = 0;
for (const root of roots) {
for (const file of walk(root)) {
readFileSync(file, "utf8")
.split("\n")
.forEach((line, index) => {
const hit = TOKENS.find((t) => t.re.test(line));
if (!hit) return;
candidates += 1;
console.log(`${file}:${index + 1}: [${hit.name}] ${line.trim().slice(0, 120)}`);
});
}
}
console.log(`candidates: ${candidates}`);
process.exit(candidates === 0 ? 0 : 1);
JS
printf 'export default {\n fetch() {\n return new Response("ok");\n },\n};\n' \
> "$DIR/clean/src/worker.js"
printf 'const make = new Function("a", "return a + 1");\nexport default {\n fetch() {\n return new Response(String(make(41)));\n },\n};\n' \
> "$DIR/dirty/src/worker.js"
echo "=== clean tree ==="
node "$DIR/scan_eval.mjs" "$DIR/clean"
echo "exit=$?"
echo "=== dirty tree ==="
node "$DIR/scan_eval.mjs" "$DIR/dirty"
echo "exit=$?"
echo "=== no arguments ==="
node "$DIR/scan_eval.mjs"
echo "exit=$?"
Its stdout:
=== clean tree ===
candidates: 0
exit=0
=== dirty tree ===
/tmp/eval-scan-demo/dirty/src/worker.js:1: [new Function] const make = new Function("a", "return a + 1");
candidates: 1
exit=1
=== no arguments ===
usage: node scan_eval.mjs <dir> [<dir> ...]
exit=2
Wire the exit code into the deploy job and the class of failure disappears from production: node scan_eval.mjs dist fails the job when the bundle contains a token the request context cannot execute. The scan is deliberately blind to reachability — a match inside a library's unused branch is still reported, and that is the correct default for a check whose cost of being wrong is asymmetric.
What is engine-side and what is platform-side
Every string in this post came out of workerd 2026-09-15 on this host, plus the two documented lists quoted above. Three things are platform-side and are labelled as such rather than measured:
The tail stream. Reading the exception on Cloudflare's network means wrangler tail, whose event objects carry an exceptions field. I have not run it in this lab; the log excerpt above is the same event from the engine that produces it.
The error code. An uncaught exception in a Worker is reported as error 1101 (“Worker threw a JavaScript exception”) in the documentation's error table, alongside the neighbouring 1102 for a CPU-limit breach. Treat those codes as the dashboard's vocabulary, not as something the runtime prints to the client: the client saw 500 Internal Server Error in every run here.
Whether the platform's isolate start-up path differs from this one. The module-scope result above is a measurement on workerd and nothing more. If you have a dependency that evaluates a string at import time and you want certainty, the honest test is a request against the deployed Worker, not a local one.
Field notes
- Symptom: one route returns
500 Internal Server Errorwhile its neighbours are fine, and the deploy that introduced it showed no error. - First command:
wrangler tail, then read theexceptionsentries. The stack's top frame names the file and line. - What you will find:
EvalErrorforeval/Function,CompileErrorforWebAssembly.compile, and the same policy behind both. - What will not help: indirect eval,
Functionwithoutnew,node:vm, a dynamicimport()of adata:URL. - What fixes it: precompilation at build time, a
501-with-guard for the paths you cannot rewrite yet, and a bundle scan in the deploy job.
Two neighbouring posts cover the rest of the same family: the runtime flags and bundle-default trap behind No such module "node:buffer", and the per-request ownership rules that break stashed I/O objects. Both are the same lesson as this one from a different angle: in this runtime the execution context, not your source, decides what is allowed.