What is __proto__ injection
Bottom line: JSON.parse treats __proto__ as an ordinary key; the damage happens in a recursive merge that writes to Object.prototype, so every object inherits attacker properties.

Bottom line: block __proto__, constructor and prototype before any merge, merge untrusted input into Object.create(null), and add a prototype assertion to CI; patching libraries alone is not enough.
In one sentence: an untrusted key named __proto__ meets a recursive merge, and the merge writes to the prototype.
A reproduction
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = target[key] || {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
console.log({}.isAdmin); // true
No business object was touched. Object.prototype was, and every object now carries isAdmin.
Why JSON.parse is not at fault
const data = JSON.parse('{"__proto__": {"a": 1}}');
Object.getOwnPropertyNames(data); // [ '__proto__' ]
Object.getPrototypeOf(data) === Object.prototype; // true, untouched
__proto__ arrives as a plain own property. The danger is entirely in the code that later merges, deep-copies, or sets by path — _.merge, some Object.assign patterns, and countless hand-written set(obj, path, value) helpers.
Impact
- Authorisation bypass:
if (user.isAdmin)becomes unconditionally true - Data corruption: unexpected fields serialise into stored records
- Denial of service: large objects attached to the prototype grow memory permanently
- Escalation: combined with template engines or command runners, this has led to remote code execution
Four defences
Filter the keys.
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
Blocking only __proto__ is insufficient: constructor.prototype reaches the same place.
Merge into a null-prototype object.
const safeTarget = Object.create(null);
With no prototype chain, __proto__ is just a string key.
Use Object.hasOwn instead of in. in consults the prototype chain.
Keep dependencies patched. lodash, deepmerge and json-patch have all shipped fixes for this class of bug. Watch npm audit.
Verify it stays fixed
Feed a payload containing __proto__ and constructor through your merge, then assert:
console.log({}.polluted); // undefined
console.log(Object.prototype.polluted); // undefined
One assertion in CI, near-zero cost, catches regressions from later refactors.
FAQ
What keys should a merge filter out?
Block __proto__, constructor and prototype together. Filtering only __proto__ is insufficient, because constructor.prototype reaches the same prototype.
Why does Object.create(null) stop the write?
A null-prototype object has no prototype chain, so __proto__ stays an ordinary string key. It works as a data container, but it does not replace key filtering or dependency updates.
How do I test for prototype pollution in CI?
Send a payload containing __proto__ and constructor through your merge, then assert that {}.polluted and Object.prototype.polluted are undefined. One assertion costs almost nothing and catches regressions from later refactors.