Ch. 1 · JavaScript

Prototype Pollution in JavaScript: How It Works, How to Prevent It

How __proto__ and constructor.prototype keys in JSON poison every object through unsafe merges, and the defences that close the hole.

~8 min readadvancedupdated Oct 6, 2026

“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); // undefined
JavaScript

So 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;
JavaScript

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); // 1
JavaScript

Object.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 undefined
JavaScript

The 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 2
JavaScript

Step 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); // undefined
JavaScript

Because 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;
JavaScript

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'); // denied
JavaScript

Common 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.prototype reaches the same place. Block all three names.
  • “Object.assign and spread are always safe.” Neither reaches Object.prototype, but Object.assign can still change the target’s own prototype through a top-level __proto__ key.
  • Checking with in or truthiness. 'isAdmin' in user and if (user.isAdmin) both see inherited, polluted values. Use Object.hasOwn for security decisions.
  • Thinking it is only a Node problem. Client-side pollution can enable DOM XSS when libraries read options such as innerHTML from 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 } });
});
JavaScript

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;
JavaScript

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); // undefined
JavaScript

Throwing 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.

More in JavaScript

esc