Safeguard
Security Guides

How to Prevent Prototype Pollution in JavaScript

Prototype pollution turns a single crafted JSON key into process-wide corruption — and it has escalated to RCE in real Node.js apps. Here is how the attack works and four layers of defense that stop it.

Priya Mehta
Security Researcher
5 min read

Prototype pollution is the JavaScript vulnerability that sounds academic until you watch it escalate to remote code execution. It exploits something fundamental to the language — every object inherits from a shared Object.prototype — so a single attacker-controlled key can silently change the behavior of every object in your process. It has appeared in lodash, minimist, jQuery, and countless application-level merge functions. This guide explains precisely how it works, why it's so damaging, and the layered defenses that shut it down.

The one-paragraph mental model

In JavaScript, when you read obj.foo and obj doesn't have its own foo, the engine walks up the prototype chain to Object.prototype.foo. Almost every object shares that same Object.prototype. So if an attacker can make your code assign to Object.prototype.foo, then suddenly every object in your program appears to have a foo property. Pollute one prototype, affect everything.

How the injection happens

The vulnerable code is almost always a function that copies keys from untrusted input into an object using bracket notation — a deep merge, a set(obj, path, value) helper, or a query-string parser.

function setDeep(obj, path, value) {
  const keys = path.split(".");
  let cur = obj;
  for (let i = 0; i < keys.length - 1; i++) {
    cur = cur[keys[i]] = cur[keys[i]] || {};
  }
  cur[keys[keys.length - 1]] = value;
}

// Attacker sends path = "__proto__.isAdmin", value = true
setDeep({}, "__proto__.isAdmin", true);

// Now, for ANY object created afterward:
const someUser = {};
console.log(someUser.isAdmin); // true  ← polluted

The same happens with recursive merges when the source is parsed from JSON like {"__proto__": {"isAdmin": true}}, or through the constructor.prototype chain, which reaches the same place by a different path.

Why it ranges from annoying to catastrophic

The impact depends entirely on what your code — or a downstream library — does with the polluted property afterward:

  • Authorization bypass: code that checks if (user.isAdmin) now passes for everyone.
  • Denial of service: polluting a property the runtime reads internally can crash or hang the process.
  • Remote code execution via gadget chains: this is the severe case. If any library later reads a polluted property and feeds it into a dangerous sink — a template engine that concatenates it into Function(), or a child-process call that reads a polluted shell/argv option — the attacker escalates from setting a boolean to running commands. Several documented Node.js RCEs chained prototype pollution into exactly this kind of gadget.

Four layers of defense

Layer 1 — Reject dangerous keys at the boundary

Any function that copies untrusted keys must refuse __proto__, constructor, and prototype:

const BLOCKED = new Set(["__proto__", "constructor", "prototype"]);

function safeSet(obj, path, value) {
  const keys = path.split(".");
  if (keys.some((k) => BLOCKED.has(k))) throw new Error("illegal key");
  // ...proceed
}

Filtering keys is necessary but not sufficient on its own — do it and the layers below.

Layer 2 — Use data structures with no prototype to pollute

For any map built from user input, drop the prototype chain entirely:

const bag = Object.create(null); // no __proto__, no inherited props
bag[userKey] = userValue;        // nothing to pollute

// Or use Map, which never touches Object.prototype:
const m = new Map();
m.set(userKey, userValue);

Map is the cleanest answer whenever keys are attacker-controlled: it has no prototype-chain semantics at all.

Layer 3 — Freeze the prototype so writes fail

Freezing Object.prototype makes pollution attempts throw (in strict mode) or silently no-op instead of succeeding:

Object.freeze(Object.prototype);
Object.freeze(Object);

Node offers this at the runtime level too. Launch with --frozen-intrinsics to freeze built-in prototypes, or --disable-proto=delete to remove the __proto__ accessor from Object.prototype outright — a strong, low-effort mitigation for services that handle untrusted JSON.

Layer 4 — Validate with a schema

A strict schema rejects unexpected keys before they ever reach a merge. Define exactly the shape you accept and discard the rest:

import { z } from "zod";
const Settings = z.object({ theme: z.enum(["light", "dark"]) }).strict();
Settings.parse(input); // extra keys like __proto__ → validation error

A defense checklist

LayerControlEffort
BoundaryBlock __proto__/constructor/prototype keysLow
Data modelObject.create(null) or Map for user mapsLow
Runtime--disable-proto=delete, --frozen-intrinsicsVery low
Schema.strict() validation at every inputMedium
DependenciesKeep merge libs patched; scan the graphOngoing

The dependency angle you can't skip

Most prototype-pollution incidents don't come from your code — they come from a transitive dependency's merge or path-set helper. That's why the lodash (CVE-2019-10744) and minimist (CVE-2020-7598) advisories mattered so widely. You need to know which of your dependencies carry a known prototype-pollution CVE and whether your code path actually reaches the vulnerable function.

How Safeguard helps

Safeguard's software composition analysis identifies prototype-pollution CVEs across your full dependency tree and runs reachability analysis so you can tell the difference between a vulnerable function you actually call and one buried in an unused code path — the distinction that separates a real risk from noise. When a fixed version exists, automated fix pull requests bump the dependency and run it against your lockfile so remediation is a review, not a research project. Griffin AI traces the potential gadget chain from a polluted property to a dangerous sink and explains whether the finding is exploitable in your context. Compared with version-only scanners — see Safeguard vs Snyk — the reachability layer is what keeps this from becoming another wall of alerts.

Get started

Prototype pollution is defeated by defense in depth: block the keys, drop the prototype, freeze the intrinsics, and validate the schema — then let scanning cover the dependencies you don't control. Create a free account at app.safeguard.sh/register and read the reachability analysis docs at docs.safeguard.sh.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.