On this page
  1. What is prototype pollution?
  2. How does a JavaScript prototype chain work?
  3. How does an attacker exploit it?
  4. What damage does a polluted property actually do?
  5. How do I prevent prototype pollution?
  6. Where AI coding assistants get this wrong
  7. Checklist
  8. FAQ
    1. What is prototype pollution in simple terms?
    2. Is prototype pollution an injection attack?
    3. Does it affect Python, Java, or Go?
    4. How do I prevent prototype pollution?
    5. Can prototype pollution give an attacker admin access?
  9. Related topics
  10. Sources
concept

What Is Prototype Pollution?

Prototype pollution lets an attacker set properties on every object in a JavaScript app. Learn how deep-merge code enables it and how to prevent it.

Quick answer

  • Prototype pollution is a JavaScript vulnerability where an attacker adds or modifies properties on Object.prototype — the shared template every plain object inherits from.
  • Because the change is global, it silently affects objects created later anywhere in the app.
  • The usual entry point is code that merges untrusted JSON into an object using a recursive deep merge, extend, or a path-based setter.
  • Defend by rejecting __proto__, constructor, and prototype keys, using Object.create(null) or Map, and freezing Object.prototype.

What is prototype pollution?

Prototype pollution is a vulnerability in which an attacker can add or change properties on Object.prototype, the built-in object that every plain JavaScript object inherits from. Since all objects share that prototype, a single polluted property becomes visible to every object created afterward — in unrelated files, unrelated modules, and unrelated requests in the same process.

This is a JavaScript-specific problem. Python, Java, Go, and Ruby do not have a shared mutable prototype in the same sense, which is why the term does not generalise to them. The risk in those languages has a different name — unsafe deserialization — and a different mechanism.

The exploit itself is rarely a dramatic payload. It is usually a few characters in a JSON body, or a query parameter, that reach a function capable of writing arbitrary property names.

Where this bites vibecoders

The specific way this bites vibecoders is deep-merge utilities. A user-profile editor, a settings endpoint, a saved-query feature — each needs “merge this JSON patch into the existing object,” and the AI writes that merge in about ten lines, recursively copying keys. Nothing in a ten-line merge function looks dangerous, which is exactly the problem. The generated code is correct, idiomatic, and merges __proto__ just as happily as name.

How does a JavaScript prototype chain work?

Every plain object in JavaScript has a hidden link to another object it inherits properties from, and that link repeats until it reaches Object.prototype and then null. Reading a missing property walks up this chain, which is how {}.toString works even though you never defined it.

const config = { retries: 3 };

// Not an own property — inherited from the prototype.
console.log(config.toString);          // function
console.log(Object.hasOwn(config, "toString")); // false

Because reads walk the chain, writing to the prototype changes what every object appears to contain. Assigning to Object.prototype.isAdmin = true makes {}.isAdmin return true, and it does so for every object created in that process from that moment on.

That is the whole mechanism. The danger is not that the attacker writes code; it is that the attacker chooses a value that application code later trusts as a default.

How does an attacker exploit it?

Exploitation needs two things: a sink — code that sets a property on an object using an attacker-influenced key — and a source — user input that reaches it. The most common sink by far is a recursive merge.

// A deep merge, written from scratch, as an assistant typically produces it.
function merge(target, source) {
  for (const key of Object.keys(source)) {
    if (typeof source[key] === "object" && source[key] !== null) {
      if (!target[key]) target[key] = {};
      merge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

Send this body and the merge walks into the prototype:

{ "__proto__": { "isAdmin": true } }

The vulnerable pattern is not the for loop — it is the absence of any check on key. A correct version refuses the dangerous keys before it recurses:

const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]);

function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (FORBIDDEN.has(key)) continue;
    if (typeof source[key] === "object" && source[key] !== null) {
      if (!target[key]) target[key] = {};
      safeMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

Note that Object.keys does not enumerate __proto__ on a normal object literal, but it does on a JSON-parsed object — which is why this survives JSON.parse and reaches the merge intact.

What damage does a polluted property actually do?

The impact depends entirely on whether your code reads a property that is not explicitly set. These are the patterns that turn pollution into a real vulnerability:

  • Authorization. A check like if (user.isAdmin) on an object whose isAdmin was never loaded from the database now inherits true. This is prototype pollution becoming broken access control — see What Is Broken Access Control (IDOR)?.
  • Child-process options. Libraries that spawn processes read an env, shell, or argv0 property. A polluted default can turn a safe invocation into a shell command execution.
  • Template engines. Some renderers resolve options by property lookup, so a polluted option changes the output for every user.
  • Bypassing denylists. Allowlist/denylist checks that test if (options.something) can be flipped by a global default.

If your code always assigns the property it checks, pollution has nowhere to land. That is the fastest way to audit your own exposure.

How do I prevent prototype pollution?

Four defenses, best used together.

1. Reject the magic keys. Any code that copies user-supplied keys into an object must skip __proto__, constructor, and prototype. This is the single most important check and the one most often missing.

2. Use objects with no prototype. Object.create(null) and Map have no inherited properties, so a polluted Object.prototype cannot reach them. This is the strongest structural fix, and it is a small change if you adopt it from the start.

3. Freeze the prototype. Object.freeze(Object.prototype) at application startup turns a silent pollution into a loud TypeError. It can break some libraries that legitimately add extensions, so test it.

4. Catch it in static analysis. Rules that flag recursive property copies without a key check find the sink. Add one to your pipeline — see How to Add SAST Scanning to a GitHub Repo.

For the general principle of letting untrusted input reach a parser that decides what code means, see What Is Cross-Site Scripting (XSS)? and What Is SQL Injection (and Why Does AI-Generated Code Keep Writing It)?.

Where AI coding assistants get this wrong

  • Hand-rolled deep merge with no key check. The single most common real-world prototype pollution sink, and it looks completely reasonable in review.
  • extend/assign with nested user input. A shallow Object.assign is safe; a library’s deep-merge mode frequently is not, and the option name does not warn you.
  • Recursive copy helpers for “apply this patch” endpoints. GraphQL-style input merging, config patch endpoints, and profile update routes all invite this shape.
  • lodash.merge and similar trusted by default. Widely-used deep-merge libraries have had pollution issues, and their current versions mitigate known cases while new ones surface. Pin the version and read its changelog.
  • Query-string objects merged into options. Object.assign(defaults, parseQuery(search)) is a source, and search is attacker-controlled on any page.
  • No test asserting the prototype stays clean. One assertion after parsing hostile input catches the whole class.

Checklist

  • Reject __proto__, constructor, and prototype in every function that copies user-supplied keys.
  • Prefer Object.create(null) or Map for dictionaries built from untrusted input.
  • Test that a malicious __proto__ payload leaves ({}).polluted undefined.
  • Audit every deep-merge, extend, and patch-style endpoint in the codebase.
  • Pin and review the changelog of any deep-merge library you depend on.
  • Add a static analysis rule for unchecked recursive property copies.
  • Confirm authorization checks compare against a real value, not an inherited default.
  • Never pass request-controlled objects directly into child-process options.

FAQ

What is prototype pollution in simple terms?

Prototype pollution is a JavaScript bug where an attacker can add or change properties on Object.prototype, the shared template every plain object inherits from. Because the change is global, it silently affects objects created later anywhere in the app, which can disable security checks or change program behavior.

Is prototype pollution an injection attack?

It is closely related. An attacker needs a way to set a property using attacker-controlled key and value, and that usually happens in code that merges untrusted JSON into an object. The pollution is the payload; the merge function is the vulnerable sink. The same principle as SQL injection, applied to object properties instead of query text.

Does it affect Python, Java, or Go?

Not directly. Prototype pollution is specific to prototype-based languages, which in practice means JavaScript and Node.js. Other languages have their own equivalent risks, such as unsafe deserialization, but the mechanism of a shared mutable template object does not apply to them.

How do I prevent prototype pollution?

Reject the magic keys __proto__, constructor, and prototype when merging untrusted input; create objects with Object.create(null) or a Map so they have no prototype; freeze the prototype with Object.freeze(Object.prototype); and run a static analysis rule that flags recursive property copies.

Can prototype pollution give an attacker admin access?

Indirectly, yes. If your authorization code checks a property like user.isAdmin that was never loaded from the database, a polluted Object.prototype.isAdmin = true makes that check pass for every user in the process. The fix is to make authorization decisions on values you actually loaded and verified, not on inherited defaults.

Sources

Share: