Prototype Pollution in JavaScript and Node.js: Prevention Guide for 2026
What Is Prototype Pollution?
Prototype pollution is a vulnerability unique to JavaScript's prototype-based inheritance model. It occurs when an attacker manipulates __proto__, constructor.prototype, or similar properties on a user-controlled object to inject properties into the global Object.prototype. Once polluted, every object — including objects created after the pollution — inherits the injected property.
// A dangerous merge function
function merge(target, source) {
for (const key of Object.keys(source)) {
if (source[key] && typeof source[key] === 'object') {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
const userInput = JSON.parse('{"__proto__": {"isAdmin": true}}');
const config = merge({}, userInput); // ⚠️ pollutes Object.prototype!
console.log({}.isAdmin); // → true (every object is now "admin")
The consequence? A single polluted property can bypass authorization checks, disable security features, or crash your entire server — all from a JSON payload the attacker controls.
Why Startups Should Care
Prototype pollution is not a niche vulnerability. It has appeared in:
- lodash (CVE-2020-8203, CVE-2019-10744 — prototype pollution via
_.merge,_.defaultsDeep) - Express body-parsers (when processing nested JSON objects without depth limits)
- Socket.io and other real-time libraries with deep-merge config handling
- jQuery (CVE-2019-11358 —
$.extend(true, ...)) - Mongoose / MongoDB drivers (automatic object expansion from query parameters)
For a startup shipping fast, the combination of deep-merge utilities, unsanitized JSON input, and recursive object processing creates a perfect storm. I've seen a prototype pollution bug escalate from "harmless JSON parsing" to "full application compromise" in production within hours of deployment.
How Attackers Exploit It
1. Bypassing Authorization Checks
// Backend code checking admin status
function isAdmin(user) {
return user.role === 'admin';
}
// Attacker sends: {"__proto__": {"role": "admin"}}
// After a recursive merge, every object has role === 'admin'
// isAdmin({}) → true
2. Denial of Service via __defineGetter__
// Older JavaScript engines allowed getter injection
const payload = '{"__proto__": {"toString": {"__defineGetter__": "..."}}';
// Can hang the process when any object is coerced to string
3. Property Shadowing in Server-Side Rendering
// Next.js server component receives polluted query params
// Object.prototype now has .dangerous = "value",
// which shadows expected properties in component props
4. Bypassing Allowlist Validators
// A schema validator checks allowed properties
const allowed = ['name', 'email', 'role'];
function sanitize(obj) {
const clean = {};
for (const key of allowed) {
if (key in obj) clean[key] = obj[key];
}
return clean;
}
// After prototype pollution, 'isAdmin' is always "in" obj
// because it's inherited via prototype, even though it's not own
'isAdmin' in {} // → true after pollution
Prevention Patterns (with Code)
1. Use Object.create(null) for Dictionaries
The single most effective defense. Objects created with null prototype have no prototype chain — no __proto__, no constructor.prototype, no toString.
// ✅ Immune to prototype pollution
const config = Object.create(null);
// ✅ Safe for caches, lookup tables, user-data maps
const userCache = Object.create(null);
userCache[sessionId] = userData;
// ✅ Safe for parsed query parameters
const params = Object.create(null);
params[key] = value;
When to use it: Any object that serves as a map, dictionary, lookup table, or holds user-controlled keys. This is most of your JSON parsing, query-string parsing, and request body handling.
2. Strip Prototype Keys Before Merging
// ✅ Safe merge with explicit prototype key exclusion
function safeMerge(target, ...sources) {
const PROTOTYPE_KEYS = new Set([
'__proto__',
'constructor',
'prototype',
]);
for (const source of sources) {
for (const key of Object.keys(source)) {
if (PROTOTYPE_KEYS.has(key)) continue; // 🛡️ skip dangerous keys
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
// ^ also skip inherited properties that might be from a previous pollution
const val = source[key];
if (val && typeof val === 'object' && !Array.isArray(val)) {
target[key] = safeMerge(
Object.prototype.hasOwnProperty.call(target, key)
? target[key]
: Object.create(null),
val
);
} else {
target[key] = val;
}
}
}
return target;
}
3. Use hasOwnProperty Instead of in
// ❌ DANGEROUS — 'key in obj' checks the prototype chain
if ('isAdmin' in userData) { /* may be true even without own property */ }
// ✅ SAFE — only checks own properties
if (Object.prototype.hasOwnProperty.call(userData, 'isAdmin')) {
// truly own
}
// ✅ or with Object.hasOwn() (ES2022+)
if (Object.hasOwn(userData, 'isAdmin')) {
// truly own
}
4. Use Map Instead of Plain Objects
For key-value storage, Map is inherently immune to prototype pollution — keys are real entries, not properties:
// ✅ Map is safe — keys are entries, not properties
const cache = new Map();
cache.set(userInputKey, value);
// No __proto__ attack possible
// ✅ Safer for rate-limiting buckets, session stores, memoization
5. Apply Depth Limits on Recursive Processing
// ✅ Limit recursion depth to prevent prototype chain traversal
function safeDeepClone(obj, maxDepth = 5) {
const seen = new WeakMap();
function clone(value, depth) {
if (depth > maxDepth) return Object.create(null); // 🛡️ cap depth
if (value === null || typeof value !== 'object') return value;
if (seen.has(value)) return seen.get(value);
const cloneTarget = Array.isArray(value) ? [] : Object.create(null);
seen.set(value, cloneTarget);
for (const key of Object.keys(value)) {
if (key === '__proto__' || key === 'constructor') continue; // 🛡️
if (Object.prototype.hasOwnProperty.call(value, key)) {
cloneTarget[key] = clone(value[key], depth + 1);
}
}
return cloneTarget;
}
return clone(value, 0);
}
6. Validate Input Schemas Before Any Processing
import { z } from 'zod';
// or: ajv, yup, joi
const UserSchema = z.object({
name: z.string().max(100),
email: z.string().email(),
role: z.enum(['user', 'admin']).optional(),
// ⚠️ No __proto__, constructor, or prototype fields allowed
}).strict(); // 🛡️ .strict() rejects unknown keys
app.post('/api/user', (req, res) => {
// Parse BEFORE any merge or spread — rejection if __proto__ present
const result = UserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: 'Invalid payload' });
}
// Now it's safe to use the parsed data
});
Next.js-Specific Protections
Parsing Query Strings Safely
// apps/my-app/src/lib/query-parser.ts
// ❌ DANGEROUS — Object.assign or spread inherits prototype
// const params = { ...req.query };
// ✅ SAFE — extract only expected keys
export function parseSafeQuery(query: Record<string, string>, allowed: string[]) {
const params: Record<string, string> = Object.create(null);
for (const key of allowed) {
if (typeof query[key] === 'string') {
params[key] = query[key];
}
}
return params;
}
Server Action Input Validation
// apps/my-app/src/actions/user.ts
'use server';
export async function updateProfile(formData: FormData) {
// FormData entries are already safe (they don't carry __proto__),
// but if you convert to JSON, be careful
const raw = Object.fromEntries(formData.entries()); // ⚡ safe — entries are strings
const data = ProfileSchema.parse(raw); // 🛡️ validate!
}
API Route Protection with Middleware
// apps/my-app/src/middleware.ts
export function middleware(request: NextRequest) {
const contentType = request.headers.get('content-type');
if (contentType?.includes('application/json')) {
// Next.js will parse the JSON body — ensure depth limits
// by configuring bodyParser in next.config or using a custom parser
}
}
Configure depth limits in next.config.ts:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
serverComponentsBodySizeLimit: '1mb',
},
// Body parser depth limit for API routes
// (configure via custom middleware if needed)
};
Audit Checklist
Use this in your next code review or security audit:
| Check | What to look for |
|---|---|
| Recursive merge/clone | _.merge, _.defaultsDeep, $.extend(true), or custom recursive merge — these must strip __proto__/constructor |
| Object spread on user data | { ...req.body }, Object.assign({}, userInput) — use Object.create(null) target or schema parse first |
| in operator checks | Replace with Object.hasOwn() or Object.prototype.hasOwnProperty.call() |
| Plain objects as maps | Migrate to Map, WeakMap, or Object.create(null) |
| JSON parsing depth | Ensure JSON.parse doesn't recurse more than 5–10 levels |
| Schema validation | All user inputs should pass a strict schema parser (Zod, Ajv, Yup) before any object operation |
| express.json() depth | Default express JSON parser has no depth limit — configure { limit: '1mb', strict: true } |
| Next.js API routes | If you're using a custom JSON parser middleware, ensure it strips prototype keys |
Tooling Support
- Semgrep rule:
js/express-prototype-pollution— detects unsafe merge patterns in CI - eslint-plugin-security: Rule
no-prototype-builtinscatches missinghasOwnPropertychecks - Socket.dev: Runtime detection for prototype pollution in your dependency tree
- Node.js
--frozen-intrinsics(experimental): FreezesObject.prototype,Array.prototype, and other built-in prototypes to prevent any runtime pollution
# Run Node with frozen intrinsics to prevent prototype mutation
node --frozen-intrinsics server.js
Summary
Prototype pollution is a JavaScript-native vulnerability that won't show up in most SAST scanners and is invisible to traditional vulnerability databases. It's also trivially easy to introduce — one bad merge, one recursive clone, one unchecked __proto__ key.
The fix isn't complicated:
Object.create(null)for any dictionary or user-data container- Strip prototype keys (
__proto__,constructor,prototype) in every recursive operation Mapfor key-value storage- Schema validation before any object manipulation
- Depth limits on recursive processing
Apply these five patterns consistently and you eliminate an entire class of vulnerabilities that most JavaScript codebases leave wide open.
JS Security Audit
Senior JavaScript security consultants with 10+ years of experience.