JavaScript's flexibility is exactly what makes it dangerous. The same language features that let you ship fast — dynamic property access, implicit coercion, eval, prototype-based objects — are the ones that turn a small oversight into a remote code execution or account takeover. After reviewing hundreds of Node.js and browser codebases, the same handful of JavaScript security pitfalls show up again and again. Here they are, each with the vulnerable pattern and the fix, so you can grep your own code as you read.
Pitfall 1: Building queries and commands from strings
Injection is old, but JavaScript's easy template literals make it easier than ever to write. Any time user input flows into a query, a shell command, or a DOM API, you have a potential sink.
// Vulnerable: SQL injection via string interpolation
const rows = await db.query(
`SELECT * FROM users WHERE email = '${req.query.email}'`
);
// Vulnerable: command injection
exec(`convert ${req.body.filename} out.png`);
The fix is never to concatenate. Use parameterized queries and argument arrays that keep data and code separate:
// Safe: parameters are bound, never parsed as SQL
const rows = await db.query(
"SELECT * FROM users WHERE email = $1",
[req.query.email]
);
// Safe: execFile with an argument array, no shell involved
execFile("convert", [req.body.filename, "out.png"]);
Pitfall 2: eval, Function, and their disguises
eval(userInput) is obviously bad, but the same danger hides in new Function(), setTimeout("code string"), and template engines configured to allow arbitrary expressions. If an attacker controls the string, they control your runtime.
// Vulnerable — attacker-controlled math "calculator"
const result = eval(req.body.expression);
If you genuinely need to evaluate expressions, use a sandboxed expression parser (like a dedicated math library) that only understands a safe grammar, never the JavaScript engine itself. For configuration, parse JSON — don't evaluate JavaScript.
Pitfall 3: Prototype pollution from untrusted merges
This is the JavaScript-specific pitfall people miss. Because objects inherit from Object.prototype, an attacker who can set a __proto__ key during a recursive merge or a JSON.parse of nested input can poison every object in the process.
// Vulnerable deep-merge
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === "object") {
target[key] = merge(target[key] || {}, source[key]);
} else {
target[key] = source[key]; // key could be "__proto__"
}
}
return target;
}
// Payload: {"__proto__": {"isAdmin": true}} → now every object looks like an admin
Guard the dangerous keys and prefer null-prototype objects for user-controlled maps:
const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]);
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (FORBIDDEN.has(key)) continue;
// ... recurse safely
}
return target;
}
const bag = Object.create(null); // no prototype chain to pollute
Historic CVEs in lodash (CVE-2019-10744) and minimist (CVE-2020-7598) were exactly this bug in widely used libraries — which is why your own merge helpers deserve the same scrutiny.
Pitfall 4: Trusting the client for anything that matters
Client-side validation is a UX feature, not a security control. Prices, quantities, user IDs, role flags — anything the browser sends can be edited. The classic bug is an authorization check that reads a field from the request body instead of the authenticated session.
// Vulnerable: trusting a client-supplied role
if (req.body.role === "admin") { grantAdminAccess(); }
// Safe: derive authority from the verified session/token, server-side
if (req.session.user.role === "admin") { grantAdminAccess(); }
Pitfall 5: Unsafe DOM sinks and XSS
In the browser, assigning untrusted data to innerHTML, document.write, or a framework's dangerous escape hatch is how cross-site scripting happens. React's dangerouslySetInnerHTML is named that way on purpose.
// Vulnerable
element.innerHTML = `Welcome, ${userName}`;
// Safe: textContent never parses HTML
element.textContent = `Welcome, ${userName}`;
When you must render user HTML (a rich-text field), sanitize it with a maintained library like DOMPurify and serve a strict Content-Security-Policy so injected scripts can't execute even if something slips through.
Pitfall 6: Weak randomness for security tokens
Math.random() is not cryptographically secure and must never generate session IDs, password-reset tokens, or API keys. Use the crypto module:
import { randomBytes } from "node:crypto";
const token = randomBytes(32).toString("hex"); // 256 bits of real entropy
A quick JavaScript security self-audit
Run these greps against your codebase this week:
eval(,new Function(,setTimeout("— evaluation sinksinnerHTML,dangerouslySetInnerHTML,document.write— DOM sinksMath.random()near words liketoken,secret,session- string-interpolated SQL and
exec(— injection sinks - recursive merge/extend helpers — prototype pollution
How Safeguard helps you find these at scale
Grepping catches the obvious cases; it misses the ones buried in transitive dependencies and the flows that cross file boundaries. Safeguard's dynamic application security testing exercises your running app to find injection and XSS sinks that are actually reachable over HTTP, and its software composition analysis flags the same prototype-pollution and injection bugs inside the libraries you depend on — with reachability analysis so you fix the ones your code truly hits first. Griffin AI explains each finding in plain language and drafts the patch, and if you're comparing approaches, Safeguard vs Checkmarx shows how modern reachability-based analysis differs from traditional SAST.
Get started
The pitfalls above are timeless because the language features behind them aren't going away — the answer is consistent, automated checking rather than heroic code review. Sign up free at app.safeguard.sh/register to scan your JavaScript and Node.js code for these exact patterns, and see the rule catalog at docs.safeguard.sh.