“Explain prototype pollution. How could a JSON request body give every object in a Node server an isAdmin property?” Security-minded interviewers ask this for senior JavaScript and Node.js roles because it is a JavaScript-specific vulnerability class with real CVEs in popular libraries (older versions of lodash, jQuery’s extend, and many query-string and config parsers). It tests how well you understand prototypes, how __proto__ behaves differently in JSON versus object literals, and whether you think about trust boundaries in code that “just merges some options.”
A complete answer covers how the attack works, why it is so damaging, and several layered defences, not just “filter out __proto__.”
Before you start
You should understand prototype chains (a property lookup that misses on an object continues to its prototype, and almost every object’s chain ends at Object.prototype), recursive functions, and how JSON.parse turns text into objects. Knowing what a deep merge utility does will help.
The short answer
Prototype pollution happens when attacker-controlled keys such as __proto__, or constructor followed by prototype, flow into code that recursively merges objects or sets values by path. Writing target['__proto__'][key] = value walks into Object.prototype and adds key to every object in the process. Defend by rejecting those keys in merge and path-setting code, by storing untrusted key/value data in Map or Object.create(null) objects, by validating input against a schema that only copies known fields, and optionally by freezing Object.prototype at startup.
How it works
__proto__ is an accessor property on Object.prototype. Reading obj.__proto__ returns the prototype; assigning to it changes the prototype. In an object literal, { __proto__: x } also sets the prototype. But JSON.parse defines keys as plain own properties, so __proto__ becomes an ordinary own key holding a nested object.
const parsed = JSON.parse('{"__proto__": {"isAdmin": true}}');
console.log(Object.keys(parsed)); // [ '__proto__' ]
console.log(Object.getPrototypeOf(parsed) === Object.prototype); // true
console.log(({}).isAdmin); // undefinedSo parsing alone is harmless. The danger starts when a naive deep merge copies that own key onto a normal object. Reading target['__proto__'] on the target goes through the accessor and returns Object.prototype itself, and the merge then writes into it.
function naiveMerge(target, source) {
for (const key of Object.keys(source)) {
if (source[key] && typeof source[key] === 'object') {
if (!target[key]) target[key] = {};
naiveMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
naiveMerge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
const victim = {};
console.log(victim.isAdmin); // true
delete Object.prototype.isAdmin;Every object created anywhere in the process now inherits isAdmin: true. Authorisation checks like if (user.isAdmin) on a user record without that field pass. The same works through constructor.prototype: on a normal object, target.constructor is Object, and Object.prototype is one more step away.
Step-by-step walkthrough
Step 1: Find the sinks
Look for code where both the key and the value can come from outside: deep merge or extend functions, “set by path” helpers such as set(obj, 'a.b.c', value), query-string parsers that build nested objects from a[b][c]=1, and config loaders that combine files with environment input. Shallow Object.assign and spread do not pollute the global prototype, because they never read target.__proto__ and then write into the result; it is the recursive descent that reaches Object.prototype.
const shallow = Object.assign({}, JSON.parse('{"__proto__": {"polluted": 1}}'));
console.log(({}).polluted); // undefined
console.log(Object.getPrototypeOf(shallow).polluted); // 1Object.assign uses assignment, so __proto__ replaces the prototype of that one new object. That is still surprising (the copy now inherits attacker data) but it does not affect other objects. Spread, by contrast, defines own properties and leaves the prototype alone.
Step 2: Block dangerous keys in every recursive writer
Reject __proto__, constructor and prototype at every level, and only recurse into plain objects.
const BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
const isPlain = (v) => v !== null && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype;
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (BLOCKED.has(key)) continue;
const value = source[key];
if (isPlain(value)) {
if (!Object.hasOwn(target, key) || !isPlain(target[key])) target[key] = {};
safeMerge(target[key], value);
} else {
target[key] = value;
}
}
return target;
}
safeMerge({}, JSON.parse('{"__proto__": {"isAdmin": true}, "constructor": {"prototype": {"x": 1}}}'));
console.log(({}).isAdmin, ({}).x); // undefined undefinedThe Object.hasOwn check matters: without it, target[key] could find an inherited property and recurse into a shared object.
Step 3: Use structures without a prototype for untrusted keys
When keys are data (user-defined tags, header names, translation keys), store them in a Map or an object with no prototype. A null-prototype object has no __proto__ accessor, so that key is just a key.
const bag = Object.create(null);
bag['__proto__'] = { isAdmin: true };
console.log(Object.keys(bag)); // [ '__proto__' ]
console.log(({}).isAdmin); // undefined
const tags = new Map(Object.entries(JSON.parse('{"__proto__": "x", "ok": "y"}')));
console.log(tags.get('__proto__'), tags.size); // x 2Step 4: Validate at the boundary and harden the runtime
The strongest defence is to never merge raw input. Parse it, then build a new object containing only the fields your schema expects, with types checked (libraries like Zod or Ajv do this; Ajv can also strip unknown properties). As a defence in depth, Node offers --disable-proto=delete (removes the __proto__ accessor) or --disable-proto=throw, and you can freeze Object.prototype at startup.
Object.freeze(Object.prototype);
function naiveMerge(t, s) {
for (const k of Object.keys(s)) {
if (s[k] && typeof s[k] === 'object') naiveMerge((t[k] ??= {}), s[k]);
else t[k] = s[k];
}
}
try {
naiveMerge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
} catch (e) {
console.log(e.message); // Cannot add property isAdmin, object is not extensible
}
console.log(({}).isAdmin); // undefinedBecause this file is an ES module, it runs in strict mode, so the write to the frozen prototype throws a TypeError and the attack becomes a visible error. In sloppy scripts the write is silently ignored instead; either way nothing is polluted. Freezing can break older libraries that patch built-ins at runtime, so roll it out behind tests.
Worked scenario
An API let users update profile preferences with a PATCH request. The handler deep-merged the body into the stored profile. A tester sent a payload with a __proto__ key, and suddenly every request passed the admin check, because session.user.isAdmin was read from an object that never defined it.
function merge(t, s) {
for (const k in s) {
if (typeof s[k] === 'object' && s[k] !== null) merge((t[k] ??= {}), s[k]);
else t[k] = s[k];
}
return t;
}
const profile = { prefs: { theme: 'light' } };
merge(profile, JSON.parse('{"prefs": {"theme": "dark"}, "__proto__": {"isAdmin": true}}'));
const anotherUser = { name: 'guest' };
console.log(anotherUser.isAdmin ? 'ADMIN ACCESS' : 'denied'); // ADMIN ACCESS
delete Object.prototype.isAdmin;The fix stops merging arbitrary input. It picks only allowed fields, checks their types, and writes into the profile explicitly. The admin check also changes to require an own boolean property.
const ALLOWED_THEMES = new Set(['light', 'dark']);
function applyPrefs(profile, body) {
const theme = body?.prefs?.theme;
if (theme !== undefined) {
if (!ALLOWED_THEMES.has(theme)) throw new TypeError('invalid theme');
profile.prefs = { ...profile.prefs, theme };
}
return profile;
}
const isAdmin = (user) => Object.hasOwn(user, 'isAdmin') && user.isAdmin === true;
const profile = applyPrefs({ prefs: { theme: 'light' } }, JSON.parse('{"prefs": {"theme": "dark"}, "__proto__": {"isAdmin": true}}'));
console.log(profile.prefs.theme); // dark
console.log(isAdmin({ name: 'guest' }) ? 'ADMIN ACCESS' : 'denied'); // deniedCommon mistake
- “JSON.parse is vulnerable.” It is not; it creates an own
__proto__key. The vulnerability is in code that later merges or assigns that key. - “Blocking
__proto__is enough.”constructor.prototypereaches the same place. Block all three names. - “Object.assign and spread are always safe.” Neither reaches
Object.prototype, butObject.assigncan still change the target’s own prototype through a top-level__proto__key. - Checking with
inor truthiness.'isAdmin' in userandif (user.isAdmin)both see inherited, polluted values. UseObject.hasOwnfor security decisions. - Thinking it is only a Node problem. Client-side pollution can enable DOM XSS when libraries read options such as
innerHTMLfrom polluted defaults.
Verify the behavior
This test proves the safe merge blocks both payload shapes and that nothing leaks onto Object.prototype.
import test from 'node:test';
import assert from 'node:assert/strict';
const BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
function safeMerge(t, s) {
for (const k of Object.keys(s)) {
if (BLOCKED.has(k)) continue;
const v = s[k];
if (v && typeof v === 'object' && !Array.isArray(v)) {
if (!Object.hasOwn(t, k) || typeof t[k] !== 'object') t[k] = {};
safeMerge(t[k], v);
} else t[k] = v;
}
return t;
}
test('blocks __proto__ and constructor.prototype payloads', () => {
safeMerge({}, JSON.parse('{"__proto__": {"p1": 1}}'));
safeMerge({}, JSON.parse('{"constructor": {"prototype": {"p2": 1}}}'));
assert.equal(Object.hasOwn(Object.prototype, 'p1'), false);
assert.equal(Object.hasOwn(Object.prototype, 'p2'), false);
});
test('still merges ordinary nested data', () => {
assert.deepEqual(safeMerge({ a: { b: 1 } }, { a: { c: 2 } }), { a: { b: 1, c: 2 } });
});Run node --test pollution.test.mjs; both tests pass.
Follow-up questions
How would you find pollution bugs in a codebase? Search for recursive merge, extend, clone and set-by-path helpers, check dependency advisories with npm audit, and fuzz endpoints with __proto__ and constructor payloads in JSON bodies and query strings.
Does structuredClone protect you? It copies own enumerable properties, so a __proto__ own key stays an own key in the copy. It does not merge, so it does not pollute, but it does not sanitise either.
What is the impact besides privilege escalation? Denial of service (a polluted toString or length breaks code everywhere), and in some template engines or child-process option objects, remote code execution.
Why does Object.create(null) help? Without Object.prototype in the chain, there is no __proto__ accessor and no inherited properties to poison or to read by accident.
Interview exercise
Here is a path setter used to apply settings like set(config, 'ui.theme', 'dark'). Show an input that pollutes every object, then fix the function.
function set(obj, path, value) {
const keys = path.split('.');
let cur = obj;
for (const k of keys.slice(0, -1)) cur = cur[k] ??= {};
cur[keys.at(-1)] = value;
}
set({}, '__proto__.polluted', 'yes');
console.log(({}).polluted); // yes
delete Object.prototype.polluted;Answer and reasoning
The path '__proto__.polluted' makes the loop step from the config object to Object.prototype (reading __proto__ goes through the inherited accessor), and the final assignment adds polluted to it. 'constructor.prototype.polluted' works too. The fix rejects dangerous segments and only walks through own, plain-object properties.
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(`unsafe path: ${path}`);
let cur = obj;
for (const k of keys.slice(0, -1)) {
if (!Object.hasOwn(cur, k) || typeof cur[k] !== 'object' || cur[k] === null) cur[k] = {};
cur = cur[k];
}
cur[keys.at(-1)] = value;
return obj;
}
console.log(safeSet({}, 'ui.theme', 'dark')); // { ui: { theme: 'dark' } }
try {
safeSet({}, 'constructor.prototype.polluted', 'yes');
} catch (e) {
console.log(e.message); // unsafe path: constructor.prototype.polluted
}
console.log(({}).polluted); // undefinedThrowing instead of silently skipping makes attacks visible in logs. The Object.hasOwn check prevents walking into inherited objects even for names you did not think to block. A strong answer adds the layered view: validate allowed paths against a schema, and consider --disable-proto=throw in Node as a safety net.
Continue learning
Read more JavaScript interview questions and try the JavaScript MCQs. Related notes: Map versus object for lookups, property enumeration and Object.freeze limits. Official reference: Prototype pollution on MDN.