Prototype pollution is a JavaScript-specific flaw. Almost every JavaScript object inherits from one shared object, Object.prototype. When an application copies attacker-controlled keys into an object unfiltered, a key named __proto__ or the path constructor.prototype does not create a new property on that object. It reaches the shared prototype instead, and the injected property then appears on every plain object in the Node.js process or browser page that does not define it itself.
Pollution alone changes nothing visible. The damage comes from gadgets: code that reads an optional property and falls back to the polluted value. Depending on the gadget, that means a bypassed authorization check, a crashed service or code execution. OWASP Top 10:2025 lists the parent weakness, CWE-915, under A08 Software or Data Integrity Failures.
The mental model: lookups walk the prototype chain
When code reads options.isAdmin, the engine checks the object's own properties first and then walks up its prototype chain. For a plain object, the next stop is Object.prototype.
| Expression |
Without pollution |
After Object.prototype.isAdmin = true |
({}).isAdmin |
undefined |
true |
({ isAdmin: false }).isAdmin |
false |
false |
Object.hasOwn({}, 'isAdmin') |
false |
false |
Object.create(null).isAdmin |
undefined |
undefined |
Own properties always win, and null-prototype objects never see the injected value. That table is also the outline of the fix.
How it happens
The usual source is a recursive merge, clone or set-by-path helper fed with parsed JSON, a nested query string or a form body. Here is a typical hand-written merge:
function merge(target, source) {
for (const key in source) {
const value = source[key];
if (typeof value === 'object' && value !== null) {
if (typeof target[key] !== 'object' || target[key] === null) {
target[key] = {};
}
merge(target[key], value);
} else {
target[key] = value;
}
}
return target;
}
app.post('/api/preferences', express.json(), (req, res) => {
res.json(merge({}, req.body));
});
JSON.parse turns {"__proto__":{"isAdmin":true}} into an object with an ordinary own key named __proto__. Parsing is harmless; the merge is not. When the loop reaches that key, target['__proto__'] on a normal object returns Object.prototype itself, and the recursive call writes isAdmin there. The path constructor.prototype reaches the same object by another route: a plain object's constructor is Object, and Object.prototype is one property away. Helpers that descend into functions, or that assign by dotted path such as set(obj, 'constructor.prototype.isAdmin', true), follow it.
Browser code has the same problem with different sources. A hand-rolled parser that expands ?__proto__[widgetUrl]=... or a URL fragment into nested objects pollutes the page, and a gadget such as script.src = config.widgetUrl || '/static/widget.js' turns the inherited value into DOM-based XSS. Old releases of popular front-end libraries shipped deep merges with this flaw. SelfSec's Prototype Pollution module tests the server side; on the client, its JS Library Fingerprint module flags library versions with known prototype pollution CVEs, covered in vulnerable components.
A concrete authorized test
Pollution is process-wide and lasts until the process restarts, so a test must use a property nothing depends on, observe it on a separate request and put it back. Express offers a convenient gadget: before 4.18.0 it read its json spaces setting through the prototype chain, and that setting controls how res.json indents output.
First, record a baseline from an endpoint that returns JSON:
GET /api/status HTTP/1.1
Host: app.example
Then send the pollution payload to the endpoint under test:
POST /api/preferences HTTP/1.1
Host: app.example
Content-Type: application/json
{"__proto__":{"json spaces":"ssPp"}}
Now repeat the clean baseline request. If the merge polluted Object.prototype, Express uses the marker as its indentation string:
{
ssPp"status": "ok"
}
The marker appears on a request that never carried it, which reflection cannot explain. Finally, set the property to an empty string, confirm compact output returns, and note that only a restart removes it:
{"__proto__":{"json spaces":""}}
If __proto__ is filtered, repeat with {"constructor":{"prototype":{"json spaces":"ssPp"}}}. Query strings and form bodies that a parser expands into nested objects take the bracket forms __proto__[json%20spaces]=ssPp and constructor[prototype][json%20spaces]=ssPp. A second harmless gadget is status: error responses built with the http-errors module take the status from the error object, inherited values included, so a code in the 400–599 range such as 599 can surface on a failing clean request.
SelfSec runs this baseline, pollute, observe and revert sequence and shares its limit: Express 4.18.0 and later ignore Object.prototype values in app settings, so a current stack hides a vulnerable merge from this gadget. No signal is not proof of safety.
What an attacker gains
The impact depends on the gadgets the application and its dependencies contain:
- Authorization and logic bypass: checks such as
if (user.isAdmin) or if (options.skipValidation) on objects that lack the property read the polluted value.
- Denial of service: replacing a common method such as
toString with a string makes routine string conversion throw across the whole process until it restarts.
- Code execution: template-engine compile options and process-spawning options read from plain objects have been turned into server-side template injection and remote code execution in published research.
- Client-side script injection: polluted configuration that reaches a script URL, an HTML sink or
eval runs attacker-controlled JavaScript in the victim's browser.
The fix: validate keys before anything merges them
The strongest fix is not to deep-merge untrusted input at all. Validate request bodies against a schema that lists the allowed properties, then copy only those. In Node.js with Ajv:
const express = require('express');
const Ajv = require('ajv');
const ajv = new Ajv();
const validatePreferences = ajv.compile({
type: 'object',
properties: {
theme: { enum: ['light', 'dark'] },
pageSize: { type: 'integer', minimum: 10, maximum: 100 },
},
additionalProperties: false,
});
const DEFAULT_PREFERENCES = Object.freeze({ theme: 'light', pageSize: 20 });
const app = express();
app.post('/api/preferences', express.json(), (req, res) => {
if (!validatePreferences(req.body)) {
res.status(400).json({ error: 'invalid preferences' });
return;
}
res.json({ ...DEFAULT_PREFERENCES, ...req.body });
});
With additionalProperties: false, a body carrying __proto__ or constructor fails validation. Object spread defines own data properties, so even a stray __proto__ key could not change a prototype.
When a generic merge is genuinely needed, for example to layer configuration files, walk own keys only and skip the three prototype keys:
const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function isPlainObject(value) {
if (value === null || typeof value !== 'object') return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (BLOCKED_KEYS.has(key)) continue;
const value = source[key];
if (isPlainObject(value)) {
const current = Object.hasOwn(target, key) && isPlainObject(target[key]) ? target[key] : {};
target[key] = safeMerge(current, value);
} else {
target[key] = value;
}
}
return target;
}
In the browser, keep user-keyed data in a Map and accept only the parameter names the page uses:
const ALLOWED_PARAMS = new Set(['q', 'page', 'sort']);
function readParams(search) {
const params = new Map();
for (const [key, value] of new URLSearchParams(search)) {
if (ALLOWED_PARAMS.has(key)) params.set(key, value);
}
return params;
}
A Map stores entries apart from its prototype chain, so a key named __proto__ is just another string.
Finally, harden the runtime as defense in depth. Node.js can make the __proto__ accessor throw, and freezing Object.prototype after startup blocks later writes:
node --disable-proto=throw server.js
Object.freeze(Object.prototype);
Neither replaces validation: --disable-proto ignores constructor.prototype, and freezing one prototype leaves class prototypes writable. Test freezing first; some libraries patch built-ins.
Fixes that do not hold
- Blocking only
__proto__: constructor.prototype reaches the same object, and dotted or bracket paths can rebuild either.
- Filtering top-level keys only: the dangerous key can sit at any depth of a nested body.
- Validating values instead of keys: the attack lives in the property name, not the data.
- Blaming
JSON.parse: parsing creates an inert own key; the merge, clone or setter that follows does the damage.
- Relying on a WAF signature: JSON escapes such as
\u005f spell the same key differently, and the merge stays vulnerable.
- Treating a quiet scan as proof: detection depends on observable gadgets, and current frameworks remove some of them.
A developer review checklist
- Find every deep merge, clone, extend, defaults or set-by-path call that receives request, URL, message or stored data.
- Validate bodies against an allowlist schema with no additional properties before they reach any merge.
- In custom helpers, iterate own keys, skip
__proto__, constructor and prototype, and never descend into inherited values.
- Store user-keyed dictionaries in a
Map or a null-prototype object.
- Read security-relevant flags with
Object.hasOwn or from objects that have no prototype.
- Keep merge, query-string and framework dependencies current, and add
--disable-proto or frozen prototypes as defense in depth.
References