Prototype Pollution
How prototype pollution injects properties onto Object.prototype through deep-merge, query string parsers, and JSON.parse — guarded merge with a denylist, structuredClone as a safe copy, JSON.parse reviver for key filtering, qs parser fix, Object.hasOwn vs hasOwnProperty, and real CVEs.

Overview
Prototype pollution is a JavaScript vulnerability where an attacker injects properties onto Object.prototype — the base object that nearly every JavaScript object inherits from. Once a property is on Object.prototype, it appears on every plain object in the process, including objects used for access control, configuration, and routing logic.
This can silently change application behaviour (properties appearing where they shouldn't), bypass authorization checks (req.user.isAdmin becomes true via inheritance), or in server-side environments lead to Remote Code Execution.
It enters most commonly through deep-merge utilities, query string parsers, and code that assigns user-controlled keys directly to objects without validation.
How It Works
JavaScript property lookup walks the prototype chain. Every plain object ({}) has Object.prototype as its prototype. Writing to __proto__ (or constructor.prototype) during a merge operation modifies this shared prototype:
const payload = JSON.parse('{ "__proto__": { "isAdmin": true } }');
// A naive deep merge writes payload.__proto__ onto the target's prototype chain
function unsafeMerge(target, source) {
for (const key of Object.keys(source)) {
target[key] = source[key]; // key === "__proto__" writes to Object.prototype
}
}
unsafeMerge({}, payload);
const innocent = {};
console.log(innocent.isAdmin); // true — every object is now "admin"Object.keys() does include __proto__ as a string key when it's an own enumerable property on the parsed JSON result — which is why naive iteration is insufficient.
Real CVEs
CVE-2019-10744 — lodash merge / mergeWith (< 4.17.12): _.merge({}, JSON.parse('{"__proto__":{"polluted":1}}')) polluted Object.prototype. Affected millions of projects. Fixed in 4.17.12.
CVE-2019-10744 related — hoek < 4.2.1: Deep clone utility. Same class of vulnerability.
CVE-2019-11358 — jQuery $.extend(true, ...) (< 3.4.0): Deep extend with user-controlled source. Fixed in 3.4.0.
CVE-2020-7598 — minimist < 0.2.1: Query string parser used by millions of packages. Parsed --__proto__.polluted=1 into Object.prototype.
CVE-2021-3757 — immer < 8.0.1: The immutable state library used by Redux Toolkit. produce({}, draft => { draft.__proto__.polluted = 1 }) polluted the prototype. Fixed in 8.0.1.
Code Examples
Vulnerable: Naive Recursive Merge
// ❌ VULNERABLE — do not use
function unsafeMerge(target: Record<string, any>, source: Record<string, any>) {
for (const key of Object.keys(source)) {
if (source[key] !== null && typeof source[key] === "object") {
target[key] = target[key] ?? {};
unsafeMerge(target[key], source[key]); // recurses into __proto__
} else {
target[key] = source[key]; // __proto__.x = y — prototype polluted
}
}
}
const userInput = JSON.parse('{ "__proto__": { "canDelete": true } }');
unsafeMerge({}, userInput);
const record = {};
console.log(record.canDelete); // true — Object.prototype is now pollutedSafe: Guarded Merge with Denylist
// ✅ SAFE — explicit denylist for prototype-walking keys
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
export function safeMerge(
target: Record<string, unknown>,
source: Record<string, unknown>,
depth = 0,
): Record<string, unknown> {
if (depth > 20) return target; // prevent stack overflow on deeply nested payloads
for (const key of Object.keys(source)) {
if (DANGEROUS_KEYS.has(key)) continue; // skip — never assign these
const srcVal = source[key];
const tgtVal = target[key];
if (
srcVal !== null &&
typeof srcVal === "object" &&
!Array.isArray(srcVal)
) {
target[key] = tgtVal && typeof tgtVal === "object" ? tgtVal : {};
safeMerge(
target[key] as Record<string, unknown>,
srcVal as Record<string, unknown>,
depth + 1,
);
} else {
target[key] = srcVal;
}
}
return target;
}Safe: structuredClone() as a Safe Deep Copy
structuredClone() (available in Node 17+ and all modern browsers) creates a deep clone using the structured clone algorithm, which does not walk prototype chains:
// ✅ structuredClone — safe deep copy that ignores __proto__ keys
const userInput = JSON.parse(
'{ "__proto__": { "isAdmin": true }, "name": "Alice" }',
);
// structuredClone copies own enumerable properties only — __proto__ is not cloned
const safeCopy = structuredClone(userInput);
const test = {};
console.log(test.isAdmin); // undefined — prototype was not polluted
// Also useful for deep copying state without mutation risk
const settings = structuredClone(defaultSettings);
settings.theme = "dark"; // mutation is isolated to the copystructuredClone does not preserve class instances, functions, or DOM nodes —
it only works with serialisable values (plain objects, arrays, primitives,
Date, Map, Set, ArrayBuffer). For these constraints, use it for plain
data deep copies and be aware of its limitations.
Safe: JSON.parse with Reviver for Key Filtering
The JSON.parse reviver function is called for every key-value pair. Use it to strip dangerous keys at parse time:
// Reviver that blocks prototype-polluting keys
function safeReviver(key: string, value: unknown): unknown {
if (key === "__proto__" || key === "constructor" || key === "prototype") {
return undefined; // strips the key entirely
}
return value;
}
// Usage
const raw = '{ "__proto__": { "isAdmin": true }, "name": "Alice" }';
const safe = JSON.parse(raw, safeReviver);
console.log(safe.name); // "Alice"
console.log(safe.__proto__); // undefined — stripped by reviverThis is useful in high-performance paths where you want to avoid schema validation overhead for simple input filtering.
Safe: Zod Schema Validation (Best Practice)
Schema validation strips unknown keys before they reach any merge logic:
// app/api/preferences/route.ts
import { z } from "zod";
import { db } from "@/lib/db";
const PreferencesSchema = z.object({
theme: z.enum(["light", "dark", "system"]),
language: z.string().max(10).optional(),
timezone: z.string().max(50).optional(),
});
// Zod's strict mode rejects any key not in the schema — including __proto__
export async function PATCH(req: Request) {
const body = await req.json();
// z.parse() throws on __proto__ — it's not a valid schema key
const parsed = PreferencesSchema.strict().parse(body);
// ↑ .strict() rejects extra keys; without it, extra keys are stripped silently
await db.userPreferences.update({ where: { userId: "..." }, data: parsed });
return Response.json({ ok: true });
}qs Query String Parser — Vulnerability and Fix
The qs library parses nested query strings: ?a[b]=1 → { a: { b: "1" } }. It also parsed ?__proto__[isAdmin]=1 into Object.prototype in older versions:
# Vulnerable: qs < 6.7.3 (CVE-2022-24999)
# Safe: qs >= 6.7.3 — allowPrototypes defaults to false
npm install qs@latestimport qs from "qs";
// ✅ Modern qs — __proto__ keys are ignored by default
const parsed = qs.parse("__proto__[isAdmin]=1");
console.log(parsed); // {}
console.log({}.isAdmin); // undefined — prototype not polluted
// Explicit safety (redundant with modern qs, but documents intent)
const safeParsed = qs.parse(queryString, { allowPrototypes: false });Always audit query string parsers when processing URL parameters server-side.
Object.hasOwn() vs hasOwnProperty
hasOwnProperty can be overwritten by prototype pollution:
const obj: any = {};
obj.hasOwnProperty = () => true; // overwrite the method
obj.hasOwnProperty("anything"); // ← always returns true — unreliable
// ✅ Object.hasOwn — static method, cannot be shadowed
Object.hasOwn(obj, "realKey"); // reliable: true only if obj has realKey as own property// Safe property existence check in merge/access code
function safeGet<T>(obj: Record<string, T>, key: string): T | undefined {
// Object.hasOwn checks own properties without prototype chain traversal
return Object.hasOwn(obj, key) ? obj[key] : undefined;
}Object.create(null) — No Prototype Data Container
For lookup tables and caches that must never inherit from Object.prototype:
// ✅ Pure dictionary — Object.getPrototypeOf(store) === null
const store = Object.create(null) as Record<string, string>;
store["userId:123"] = "Alice";
store["userId:456"] = "Bob";
// No __proto__, no toString, no hasOwnProperty — truly key-value only
// A __proto__ key is stored as a normal own property, not prototype access
store["__proto__"] = "harmless string";
console.log(Object.getPrototypeOf(store)); // null — prototype never changedFreezing Object.prototype (High-Security Contexts)
// server/init.ts — call before processing any user input
// Prevents all prototype pollution by making Object.prototype immutable
if (process.env.NODE_ENV === "production") {
Object.freeze(Object.prototype);
Object.freeze(Function.prototype);
Object.freeze(Array.prototype);
}
// After freeze: any attempt to write to Object.prototype silently fails in sloppy mode
// or throws TypeError in strict mode — pollution becomes a no-opFreezing Object.prototype can break third-party libraries that legitimately
extend it (some polyfills, older testing libraries). Test thoroughly in a
staging environment before applying to production.
Real-World Use Case
Node.js API accepting user preferences. A PATCH endpoint accepts { "theme": "dark", "__proto__": { "isAdmin": true } }. The handler uses Object.assign(defaults, body). Because Object.assign copies own enumerable properties, it writes __proto__ as a key — which JavaScript interprets as prototype access, polluting Object.prototype.isAdmin = true for the entire server process.
Later in the same request, middleware checks if (req.user.isAdmin). The user has no explicit isAdmin field, but inherits true from the now-polluted Object.prototype. The attacker has escalated privileges.
Zod schema validation on the route handler prevents this entirely — __proto__ is not a declared schema field, and Zod's parser strips or rejects it before any object assignment.
Common Mistakes / Gotchas
1. Trusting that Object.keys() iteration is safe. Object.keys() returns __proto__ as a string key when it's an own property on the parsed JSON object — assigning it through bracket notation (obj[key] = val where key === "__proto__") triggers prototype mutation. Key iteration is not sufficient; the denylist check must come before the assignment.
2. Assuming deep merge utilities are safe by default. Lodash merge before 4.17.12, hoek before 4.2.1, and jQuery $.extend before 3.4.0 all had prototype pollution CVEs. Run npm audit and verify you're on patched versions.
3. Not checking query string parsers. URL parameters like ?__proto__[admin]=1 are a common attack vector for server-side prototype pollution via qs, querystring, or custom parsers. Audit all query string parsing in your API routes.
4. Using obj.hasOwnProperty(key) for security-critical checks. Prototype pollution can replace hasOwnProperty on an object. Use Object.hasOwn(obj, key) which cannot be shadowed.
5. Forgetting that pollution is process-wide. Prototype pollution in Node.js persists for the lifetime of the process (or until the property is deleted). A single polluted request affects all subsequent requests handled by the same process. This makes the attack particularly severe in long-running server environments.
Summary
Prototype pollution injects properties onto Object.prototype through naive deep-merge, query string parsers, or bracket-notation assignment of user-controlled keys. The denylist (__proto__, constructor, prototype) is the minimum defence in custom merge code; Zod schema validation is the most ergonomic production defence. structuredClone() produces a safe deep copy that ignores prototype chain traversal. Use Object.create(null) for pure data containers that must never inherit from Object.prototype. Upgrade lodash ≥ 4.17.12, qs ≥ 6.7.3, and jQuery ≥ 3.4.0 to get patched implementations. Use Object.hasOwn() instead of obj.hasOwnProperty() in security-critical checks. Pollution persists for the entire Node.js process lifetime — treat it as equivalent in severity to SQL injection.
Interview Questions
Q1. How does prototype pollution work mechanically and why is __proto__ the key attack vector?
JavaScript property access walks the prototype chain. Every plain object ({}) has Object.prototype at the root of its prototype chain. The __proto__ property is a special accessor defined by JavaScript engines that, when assigned through bracket notation (obj["__proto__"] = something), sets the object's internal prototype rather than creating an own property. When a naive deep-merge iterates Object.keys(source) and assigns each key, key === "__proto__" triggers this prototype-setting behaviour on the target object. If the target is {} or any plain object, this writes onto Object.prototype — a singleton shared by all plain objects in the process. Subsequently, any plain object that doesn't have the property as an own property inherits it.
Q2. What are three distinct entry points for prototype pollution in a Node.js application?
First, deep-merge utilities: any function that recursively assigns source properties to a target without a denylist check. This includes hand-written merge functions and older versions of lodash, hoek, and jQuery's $.extend. Second, query string parsers: qs.parse("?__proto__[isAdmin]=1") in vulnerable versions reconstructs __proto__ as a nested object key and assigns it. URL parameters accepting nested structure are a reliable attack surface. Third, JSON.parse combined with property assignment: JSON.parse('{"__proto__":{"x":1}}') produces a plain object with __proto__ as an own string key. If code iterates this object and assigns to another object without filtering, prototype pollution occurs. Any parsing of user-controlled data followed by non-validated object assignment is a potential entry point.
Q3. How does structuredClone() protect against prototype pollution compared to JSON.parse(JSON.stringify(obj))?
Both create a deep copy that doesn't share references with the original. structuredClone() is more reliable because it handles types that JSON.stringify cannot (Date, Map, Set, ArrayBuffer, undefined, circular references within the value). More importantly for security: structuredClone() uses the structured clone algorithm which copies only own enumerable data properties — it does not copy prototype-affecting keys. JSON.parse(JSON.stringify(obj)) also produces a clean deep copy for the same reason — JSON serialisation only includes own enumerable properties, and JSON.parse on that string produces a fresh object. The practical difference: structuredClone is faster, preserves more types, and doesn't convert undefined values to null. Neither approach prevents pollution if you then assign properties from the clone back to another object through a naive merge.
Q4. What specific CVEs in widely-used npm packages involved prototype pollution?
The most impactful: lodash _.merge and _.mergeWith (CVE-2019-10744, lodash < 4.17.12) — affected an enormous number of projects since lodash was the most downloaded npm package. jQuery $.extend(true, ...) (CVE-2019-11358, jQuery < 3.4.0) — affected countless legacy web applications. minimist (CVE-2020-7598, < 0.2.1) — a tiny argv parser used by hundreds of thousands of packages as a transitive dependency; --__proto__.polluted=1 in CLI arguments polluted the prototype. immer (CVE-2021-3757, < 8.0.1) — used by Redux Toolkit; producing a draft that modified __proto__ polluted the prototype. These CVEs demonstrate that prototype pollution is not a theoretical concern — it has affected fundamental, widely trusted libraries.
Q5. Why is Object.hasOwn(obj, key) safer than obj.hasOwnProperty(key) in security-sensitive code?
hasOwnProperty is an inherited method — it lives on Object.prototype and can be overwritten. Prototype pollution itself can replace hasOwnProperty on an object: obj.hasOwnProperty = () => true makes every subsequent obj.hasOwnProperty(key) return true regardless of reality. In an environment where user data is processed, this can be exploited to bypass checks like if (config.hasOwnProperty("adminKey")). Object.hasOwn(obj, key) is a static method on Object that cannot be shadowed by per-object property assignment — it always uses the built-in implementation. For any security-relevant check about whether an object has a specific own property, Object.hasOwn is the correct choice.
Q6. How does Zod schema validation prevent prototype pollution and why is it superior to a manual denylist?
Zod's schema parser creates a new object containing only the properties declared in the schema. If __proto__ is not in the schema, it is silently stripped (in default mode) or causes a validation error (in .strict() mode). The validated output is a freshly constructed object — it does not contain any of the attacker's extra keys, regardless of what the input contained. This is superior to a manual denylist for three reasons: (1) it's exhaustive rather than defensive — you declare what's allowed rather than blocking known bad keys, so unknown future attack vectors are also blocked; (2) it validates types and values simultaneously, not just key names; (3) it's compositional — schema validation is required for input correctness anyway, so prototype pollution protection comes for free. A manual denylist only blocks known keys and requires maintenance as new attack patterns emerge.
Secrets Management
Keeping API keys, tokens, and credentials out of source control and client bundles — server-only boundaries, t3-env startup validation, gitleaks pre-commit scanning, detecting bundle leaks, Doppler/HashiCorp Vault integration, and secret rotation strategies.
Supply Chain Risks
Protecting your project from dependency supply chain attacks — lockfile hygiene, npm audit, Socket proactive scanning, Subresource Integrity for CDN scripts, package.json overrides for transitive vulnerability patching, npm provenance attestations, GitHub Dependabot, and SBOM generation.