← All posts
·6 min read·GuardLayer

eval() in JavaScript: the one line that turns user input into remote code execution

JavaScriptevalRCENode.js

eval() does exactly one thing: it takes a string and runs it as JavaScript, with the full privileges of the code that called it. That's the entire feature. There's no sandbox, no allowlist, no "expressions only" mode. Whatever you pass in executes in your process — same scope, same imports, same filesystem and network access.

So the danger isn't subtle. The moment any part of the string handed to eval() comes from outside your code — a request body, a query string, a config file someone else can edit, a database row — you no longer have a calculator. You have remote code execution.

Why "it only evaluates math" is a trap

The classic excuse is "I only use it to evaluate simple arithmetic." But eval doesn't know or care that you intended arithmetic. The string 2 + 2 and the string process.exit(1) are equally valid JavaScript, and eval runs both. There is no parser flag that restricts it to the subset you had in mind.

Here's the canonical mistake — a tiny Next.js API route that "just" evaluates a formula a user types in:

// app/api/calc/route.ts - evaluates a user-supplied formula
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const { formula } = await req.json();

  // Run the caller-supplied expression to compute a result.
  const result = eval(formula);

  return NextResponse.json({ result });
}
guardlayer scan · app/api/calc/route.tsLive engine output
Passed with warnings
84/100 · B
  • Warningapp/api/calc/route.ts:5

    API route without input validation

    Validate the parsed body against a schema (e.g. zod safeParse) before use, and return 400 on failure. Consider rate limiting for unauthenticated routes.
  • Warningapp/api/calc/route.ts:8

    Use of eval()

    Remove eval()/new Function(). Parse data with JSON.parse, dispatch via a lookup table, or use a safe expression evaluator.

This works perfectly in the demo. {"formula": "3 * (4 + 1)"} returns 15. Ship it.

Now an attacker sends this body instead:

{ "formula": "require('child_process').execSync('cat /etc/passwd').toString()" }

On a Node.js server, eval happily resolves require, spawns a shell, and returns the file contents in your JSON response. Swap that payload for rm -rf, an outbound fetch that exfiltrates your environment variables (process.env holds your database URL and API keys), or a reverse shell, and the same one line owns the box. In the browser the blast radius is different but just as bad: the attacker's string runs with your user's session, reads document.cookie, and fires authenticated requests as them.

The CWE for this is CWE-95: Eval Injection, and it's been in the wild for as long as the function has existed.

new Function() is the same hole wearing a hat

People who've been told "don't use eval" often reach for the Function constructor instead:

const result = new Function("return " + formula)();

This is not safer. new Function(body) compiles and runs body as a function — it's eval with one fewer stack frame. It can't see the local variables around the call the way eval can, but it runs in global scope with full access to globals, require/import, process, and the network. Treat new Function(userInput) as identical to eval(userInput). (setTimeout("code") and setInterval("code") with a string first argument are the same trick again.)

Is eval ever safe?

Strictly: eval is safe only when 100% of the string is a literal you wrote and none of it is derived from input you don't fully control. In practice that's almost never true — and when it is, you didn't need eval, because you could have written the code directly. The honest rule of thumb: if you're typing eval( and the argument isn't a hardcoded constant, stop. There's a better primitive for whatever you're doing.

The fix: don't run strings, parse them

The right replacement depends on what you were actually trying to do. Almost every real use of eval falls into one of three buckets.

1. You're parsing data (JSON). This is the single most common reason eval shows up — old code that did eval('(' + jsonString + ')'). Use the parser built for it:

const data = JSON.parse(jsonString); // throws on bad input, never executes it

JSON.parse reads data. It cannot run code. Wrap it in try/catch and you've removed the entire attack surface.

2. You're dispatching on a name. If the input picks which operation to run, use a lookup table instead of building code from the name:

const ops: Record<string, (a: number, b: number) => number> = {
  add: (a, b) => a + b,
  sub: (a, b) => a - b,
  mul: (a, b) => a * b,
};
const fn = ops[op];
if (!fn) return NextResponse.json({ error: "unknown op" }, { status: 400 });
const result = fn(a, b);

Now only the three behaviours you defined can ever run. Unknown input is rejected, not executed.

3. You genuinely need to evaluate user math. Use a real expression library that parses to an AST and evaluates that, never the raw string — e.g. mathjs:

import { evaluate } from "mathjs";

// Parses a math grammar — no require, process, or DOM in its evaluator.
const result = evaluate(formula);

mathjs understands 3 * (4 + 1) but has no concept of require or process, so the malicious payload from earlier fails to parse rather than executing. That's the whole point: the evaluator's grammar is your allowlist. One caveat — older mathjs releases had sandbox-escape advisories, so pin a current version (10.x or newer) and keep it patched. The grammar is the safety boundary, not the function name.

30-second self-check

Grep your repo for the usual suspects:

# eval() and the Function constructor
grep -rnE "\beval\s*\(|new\s+Function\s*\(" src/ app/

# string-argument timers (same hole)
grep -rnE "set(Timeout|Interval)\s*\(\s*['\"\`]" src/ app/

For each hit, ask one question: can any part of that argument trace back to input I don't control? Request bodies, query params, headers, URL fragments, files, DB rows, third-party API responses — all of it counts. If the answer is anything other than a hard "no," it's a finding.

FAQ

Is eval slow, or just dangerous? Both. Engines can't optimise code they only see at runtime, so eval defeats JIT optimisations in the surrounding scope. But the security problem is the reason to remove it — the performance hit is a bonus.

I sanitise the input with a regex before eval. Am I safe? Treat that as "no." Allowlist-by-regex for a Turing-complete language is a losing game; people bypass these filters with Unicode escapes, property-access tricks, and template literals. Parse the input with the right tool instead of trying to bless a string for execution.

Is new Function() safer than eval because it has its own scope? No. It can't read the local variables around the call site, but it still runs arbitrary code with full global and module access. For untrusted input the risk is identical.

What about eval in the browser — it's just the user's own machine, right? The attacker isn't always the user. Reflected input (a URL param echoed into eval) lets one person craft a link that runs code in another user's authenticated session — that's DOM-based XSS via eval, and it steals sessions, not just self-harm.

Does a Content Security Policy stop this? A strict CSP without 'unsafe-eval' blocks eval/new Function in the browser, which is a great defence-in-depth layer. It does nothing for server-side Node, and it's a backstop, not a substitute for removing the call.

GuardLayer flags eval() and new Function() on real call sites (not mentions in comments or docs) on every push, alongside hardcoded secrets, SQL injection, and unsafe HTML — with the fix inline.

Catch this before it ships — free

GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.

Keep reading