Here's a strange property of JavaScript that most developers don't think about until it bites them.
Suppose you modify an object. Not a global, not a module export, just a regular object somewhere in your application. Then somewhere completely unrelated, another object you never touched starts behaving differently. Code that worked before now sees properties it shouldn't have.
How does modifying one object affect another you never directly interacted with?
The answer involves the prototype chain, and understanding it is what makes prototype pollution make sense.
How JavaScript Objects Inherit Properties
Every JavaScript object has an internal link to another object called its prototype. When you access a property on an object, JavaScript first looks at the object's own properties. If it doesn't find the property there, it follows the link to the prototype and looks there. If it's still not found, it follows the prototype's link, and so on. This chain continues until it either finds the property or reaches the end of the chain.
myObject
↓ (own properties checked first)
myObject.__proto__
↓ (then its prototype)
Object.prototype
↓
null (end of chain)
Most plain objects in JavaScript share the same prototype at the top of this chain: Object.prototype. It's the common ancestor. Properties defined there are inherited by virtually every object in the runtime.
This is normally useful. Methods like toString, hasOwnProperty, and valueOf exist on Object.prototype, which is why you can call them on any object without defining them yourself. The prototype chain is how JavaScript implements inheritance without requiring you to copy methods onto every object.
Own Properties vs Inherited Properties
The distinction between own properties and inherited properties is important and often overlooked.
const obj = { name: "Alice" };
obj.name // own property — defined directly on obj
obj.toString // inherited — comes from Object.prototype
If you check "name" in obj, you get true. If you check "toString" in obj, you also get true, even though toString was never assigned to obj directly. The in operator traverses the prototype chain. Object.hasOwn(obj, "toString") returns false, because hasOwn checks only own properties.
This is normal behavior. The problem begins when untrusted data can influence what properties exist on Object.prototype.
What Prototype Pollution Actually Is
Prototype pollution occurs when attacker-controlled input reaches a code path that modifies a shared prototype, most often Object.prototype.
A common vulnerable pattern is a deep-merge or recursive-assign function that accepts untrusted input:
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === "object") {
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
This looks reasonable. But consider what happens if the source object contains a key like __proto__:
merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
The loop reaches the key __proto__. It treats it like any other key and recursively merges into target.__proto__, which is Object.prototype. Now Object.prototype has a property isAdmin set to true.
From this point on, every plain object in the application inherits isAdmin:
const user = {};
console.log(user.isAdmin); // true — never assigned, but inherited
The user object wasn't modified. Nothing about how it was created changed. But because Object.prototype is the shared ancestor, every object that looks up isAdmin and doesn't find it as an own property will find it inherited.
Why This Becomes a Security Vulnerability
Prototype pollution by itself introduces an unexpected property. The security impact depends on what the rest of the application does with inherited properties.
Consider code like this somewhere in the application:
if (options.isAdmin) {
// grant elevated access
}
If the developer wrote this assuming isAdmin can only be present when explicitly assigned to options, the assumption is now broken. After pollution, every options object, regardless of where it was created or what it contains, inherits isAdmin: true through the prototype chain.
This is the second-order behavior that makes prototype pollution dangerous. The pollution itself is just a property assignment to a shared object. The vulnerability comes when other code later trusts that an inherited property reflects intentional state.
The impact varies significantly depending on what polluted properties are consumed and how. In some applications it causes unexpected logic branches. In codebases that make authorization decisions based on properties like isAdmin, role, or enabled, it can bypass those decisions. In environments where polluted properties reach configuration paths or template engines, the consequences can go further. But prototype pollution doesn't automatically produce a specific impact. The severity depends entirely on the code paths that read the polluted properties.
Why Unsafe Merge Logic Is the Common Entry Point
Deep-merge and recursive-assign utilities appear throughout JavaScript codebases. They're used for combining configuration objects, handling default options, processing user preferences, and merging request bodies into application state.
Many older implementations were written without considering that the keys themselves could be prototype-related. Keys like __proto__, constructor, and prototype have special meaning in JavaScript's object model. A merge function that treats these as ordinary keys and assigns through them can modify the shared prototype.
The vulnerability isn't in JavaScript prototypes themselves. It's in unsafe data flows: situations where the keys and values of untrusted input reach a code path that modifies objects without checking whether those keys should be treated specially.
Defenses
Check own properties explicitly when inherited properties shouldn't count.
if (Object.hasOwn(options, "isAdmin") && options.isAdmin) {
// ...
}
This ensures the check only passes when the property was explicitly assigned to that specific object. An inherited property from a polluted prototype won't satisfy this check.
Filter prototype-related keys in merge functions.
Any merge or assign utility that processes untrusted input should explicitly reject keys like __proto__, constructor, and prototype before assigning.
Use Object.create(null) for plain dictionaries.
An object created with Object.create(null) has no prototype. It can't be polluted via __proto__ assignment, and it doesn't inherit anything from Object.prototype. For use cases where you need a key-value store without prototype chain baggage, this is a safer choice.
const safeMap = Object.create(null);
Don't merge untrusted input into arbitrary objects.
The most direct defense is not passing user-controlled data into deep-merge functions that operate on plain objects. If the application needs to incorporate external input into configuration or state, validate and constrain that input first.
Use well-maintained libraries and keep them updated.
Several popular JavaScript libraries had prototype pollution vulnerabilities in their merge, deep-clone, or path-assignment utilities. Many have been patched. Keeping dependencies updated means you benefit from those fixes.
The dangerous part of prototype pollution isn't that JavaScript has prototypes. Prototypes are a legitimate design decision and they work correctly for their intended purpose.
The dangerous part is that untrusted input can sometimes reach a code path that modifies Object.prototype, and because that object is the shared ancestor of nearly every plain object in the runtime, the effect propagates invisibly. Code you didn't touch, in parts of the application far removed from where the input arrived, inherits properties it was never supposed to have.
The difference between "this object has the property" and "this object inherits the property" is exactly the gap that prototype pollution exploits.
Top comments (1)
What gets me about prototype pollution is that the entry point is almost never exotic — it's a plain deep-merge over user-supplied JSON where nobody blacklisted
__proto__andconstructor. Any config path that round-trips through JSON.parse and merges recursively is a candidate.In pipelines that ingest model-generated config we now allowlist keys before merging rather than trying to sanitize values, because the deny-list always lags behind. Do you have a stance on
Object.freeze(Object.prototype)as a blunt first line, or is the debuggability cost too high in production?