Prototype pollution
You've built a clean REST API that merges user-provided configuration with your defaults, or you're processing a JSON file from an untrusted source. Then suddenly, every object in your entire application starts behaving strangely—methods fail, properties that should be undefined exist everywhere. That's prototype pollution, a security vulnerability where attackers manipulate the __proto__, constructor, or prototype properties to poison the prototype chain and affect every object in the application.
I've seen this happen in production when a team trusted JSON merge operations too much. By the end of this article, you'll understand exactly why it happens and how to defend against it.
Deep merge of user JSON is dangerous
The most common entry point is a recursive merge function that accepts user input. Picture this scenario: you're building a SaaS platform where customers can override default settings. You write a helpful merge utility:
function deepMerge(target: any, source: any): any {
for (const key in source) {
if (source[key] && typeof source[key] === 'object') {
target[key] = deepMerge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
const defaults = { debug: false, timeout: 5000 };
const userConfig = JSON.parse(userInput);
const config = deepMerge(defaults, userConfig);
Now an attacker sends this JSON:
{
"__proto__": {
"isAdmin": true,
"bypassAuth": true
}
}
When your code processes this with deepMerge, it traverses into the __proto__ key and modifies Object.prototype. Suddenly, every plain object in your app—including new user instances, database models, and request objects—inherits isAdmin: true and bypassAuth: true. Your entire permission system breaks.
The danger lies in two things: first, the for...in loop enumerates inherited properties including __proto__; second, assigning to target[key] when key is __proto__ actually modifies the prototype chain, not a regular property. This is why I call it pollution—it's not localized to one object, it spreads to the entire inheritance tree.
Block __proto__, constructor and prototype keys
The most direct fix is to reject or skip these dangerous keys entirely. You have several variations of the same attack surface:
__proto__: Direct access to the prototype objectconstructor: Can lead toconstructor.prototypemanipulationprototype: If iterating deeply through constructor chains
Here's a hardened merge function:
function deepMerge(target: any, source: any): any {
const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
for (const key in source) {
if (dangerousKeys.includes(key)) {
continue; // Skip dangerous keys
}
if (source.hasOwnProperty(key)) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
target[key] = deepMerge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
}
return target;
}
Notice I also added hasOwnProperty check—this ensures we only merge direct properties of the source object, not inherited ones. Combined with the dangerous key blocklist, this stops the attack at the source.
However, this approach has a limitation: it's a blacklist, and blacklists are fragile. New JavaScript features or overlooked edge cases could bypass it. I consider this a necessary layer of defense, but never the only one.
Use Object.create(null) for dictionaries
A more robust approach is to use objects without a prototype at all. When you create an object with Object.create(null), it has no prototype chain—it inherits nothing, not even the basic Object methods. This means __proto__ assignment can't poison anything because there's no prototype to pollute.
If your use case is storing key-value pairs from untrusted sources, especially configuration or user-defined fields, prefer Object.create(null):
function safeConfig(userInput: string): Record {
// Create an object with no prototype chain
const config = Object.create(null);
const parsed = JSON.parse(userInput);
for (const key in parsed) {
if (parsed.hasOwnProperty(key)) {
config[key] = parsed[key];
}
}
return config;
}
const cfg = safeConfig('{"__proto__": {"isAdmin": true}, "timeout": 5000}');
console.log(cfg.isAdmin); // undefined
console.log(cfg.toString); // undefined (no Object.prototype methods)
console.log(cfg.timeout); // 5000
With Object.create(null), even a direct assignment to __proto__ can't pollute Object.prototype because the object has no prototype. The downside is you lose Object methods like toString, hasOwnProperty, etc., so this works best for pure data dictionaries.
In a real system, I'd use this pattern for storing customer metadata, feature flags per user, or any configuration that comes from JSON. For objects that need normal prototype behavior (domain models, service classes), I'd keep them separate and never merge user data into them directly.
Prefer Map for user-controlled keys
The cleanest solution when you're dealing with dynamic, user-controlled keys is to use JavaScript's Map. Maps store key-value pairs separate from the prototype chain, so pollution is impossible:
function loadUserPreferences(jsonString: string): Map {
const preferences = new Map();
const parsed = JSON.parse(jsonString);
for (const [key, value] of Object.entries(parsed)) {
preferences.set(key, value);
}
return preferences;
}
const prefs = loadUserPreferences('{"__proto__": {"isAdmin": true}, "theme": "dark"}');
console.log(prefs.get('__proto__')); // undefined
console.log(prefs.get('theme')); // "dark"
console.log(prefs.has('isAdmin')); // false
// Object.prototype is untouched
Why this works: Maps are built-in collections with their own internal slot for storing entries. They don't use object properties for storage, so the prototype chain is irrelevant. There's no way for a key in a Map to pollute global prototypes.
I recommend Maps for any scenario where keys come from users or external systems: API parameters, database query filters, feature flags per customer, or permission definitions. It's a small API shift—map.get(key) instead of obj[key]—but it buys you genuine safety without ongoing vigilance.
Common mistakes
- Relying only on blacklists: Blocking
__proto__,constructor, andprototypeis good practice, but libraries and frameworks evolve. Always combine it with whitelist approaches (allow only known keys) or safer data structures. - Merging directly into application objects: Never merge user JSON into domain models or singletons. Merge into isolated, purpose-built containers first, then copy safe values to your actual objects.
- Forgetting nested objects: If you have
{"settings": {"__proto__": {"isAdmin": true}}}, a recursive merge without guards will still pollute when it recurses. Your defense must apply at every recursion level. - Using regular objects for config dictionaries: If you're storing config or metadata, using
Object.create(null)orMapfrom the start is simpler than trying to sanitize every input. It's a design choice, not an afterthought. - Testing only happy paths: Add tests with `__proto__`, `constructor`, and `prototype` keys to your merge/load functions. If the test passes, you know the attack vector is blocked.
Key takeaways
- Prototype pollution happens when recursive merge operations allow assignment to
__proto__,constructor, orprototypekeys, poisoning the entire prototype chain and affecting all objects in the application. - Always block dangerous keys in merge functions and validate at every recursion level. Use
hasOwnPropertyto skip inherited properties. - For untrusted key-value data, prefer
Object.create(null)to eliminate the prototype chain entirely, preventing any form of prototype pollution. - When working with dynamic, user-controlled keys, use
Mapinstead of plain objects. Maps store data in isolated internal slots, making prototype pollution impossible. - Combine multiple defenses: blacklists, whitelists, safer data structures, and thorough testing. No single technique is complete on its own.
- Treat user-provided JSON with deep suspicion. If it can be merged, it can be weaponized unless you enforce strict boundaries.






Comments
Loading comments…