cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Prototype pollution

Learn how unsafe JavaScript merges, JSON, query strings, and path helpers can create inherited defaults, then block prototype pollution safely.

By the end, you can
  • 01
    Trace the pollution pathShow how __proto__ and constructor.prototype turn a data key into a prototype write.
  • 02
    Harden merges and path helpersSkip dangerous keys before target lookup and use own-property checks when reading decisions.
  • 03
    Choose safer containersCompare plain objects, null-prototype dictionaries, Map, frozen prototypes, validation, and Node flags.

Prototype pollution in plain words

Prototype pollution is a data-to-prototype bug. Untrusted input contains a key such as __proto__, constructor, or prototype. A merge, parser, or path setter treats that key like normal data and writes to a shared prototype. Later, ordinary property lookup makes fresh objects appear to have attacker-chosen defaults.

In one sentence: merging untrusted objects can change every object that inherits from the polluted prototype. This lesson builds on the prototype chain, Object.create, built-in prototypes, freezing and sealing, JSON, classic utilities, Map & Set, property descriptors, and XSS.

Plain definition

Prototype pollution happens when data controls a property write that reaches a prototype object, so inherited lookup later supplies values that were never intentionally set on each object.

Real-life analogyA shared apartment noticeboard

Imagine a building where every apartment door has its own notes, but everyone also reads a shared noticeboard. If an attacker can pin “all residents are staff” on the noticeboard, apartments without their own staff note appear to inherit it.

In real life: A private note on one door
In JavaScript: An own property on one object
In real life: The building noticeboard
In JavaScript: A shared prototype such as Object.prototype
In real life: A fake notice saying everyone is staff
In JavaScript: A polluted inherited default like isAdmin: true
In real life: Checking the tenant's own ID
In JavaScript: Object.hasOwn(user, "isAdmin")

Where the analogy stops: Real people can question a suspicious notice. JavaScript property lookup follows the chain mechanically unless your code asks for own properties or uses safer containers.

Safety of this lesson's demos

Runnable snippets that touch Object.prototype do it inside the lesson editor's fresh iframe or the test helper's fresh VM context, and they delete the demo property in finally. The visible step-throughs use a local sandbox prototype so the page's real Object.prototype is never changed.

Why one prototype reaches many objects

FOUNDATION

Property lookup checks an object first. If the property is missing, lookup walks the object's [[Prototype]] link. Most ordinary objects eventually reach Object.prototype. Add a property there and ({}).isAdmin can read it even though the fresh object owns nothing.

The legacy __proto__ accessor normally lives on Object.prototype. Reading obj["__proto__"] can return the object's prototype. Assigning obj["__proto__"] = other can change that one object's prototype. A deep merge is more dangerous: it may read the prototype and then recursively write into it.

Reading inherited admin flags is dangerousPop out in the code editor (opens in a new tab)JavaScript
const defaults = { isAdmin: true };const requestConfig = Object.create(defaults); console.log(requestConfig.isAdmin);console.log(Object.hasOwn(requestConfig, "isAdmin"));

Line 1 creates an object that inherits isAdmin. Line 4 uses Object.hasOwn to reject that inherited flag. Authorization, feature flags, and configuration fallbacks should make that distinction explicit.

JSON is safe until assignment

STEP THROUGH

JSON.parse does not call the __proto__ accessor. It creates an own data property whose name is literally "__proto__". That parsed object is not polluted. The danger starts later when a naive merge copies that own property with assignment or recursively merges into target["__proto__"].

JSON.parse creates an own __proto__ propertyPop out in the code editor (opens in a new tab)JavaScript
function isObjectLike(value) {  return value !== null && (typeof value === "object" || typeof value === "function");}function unsafeMerge(target, source) {  for (const key of Object.keys(source)) {    const value = source[key];    if (isObjectLike(value) && isObjectLike(target[key])) {      unsafeMerge(target[key], value);    } else {      target[key] = value;    }  }  return target;} const parsed = JSON.parse('{"__proto__":{"isAdmin":true}}');console.log(Object.hasOwn(parsed, "__proto__"));console.log(Object.getPrototypeOf(parsed) === Object.prototype);console.log(({}).isAdmin);try {  unsafeMerge({}, parsed);  console.log(({}).isAdmin);} finally {  delete Object.prototype.isAdmin;}

The first two logs prove the exact behavior: the parsed value owns the __proto__ property, and its real prototype is still Object.prototype. The third log is undefined. Only after unsafeMerge does a fresh object read true through the polluted prototype. The finally block cleans up the demo property.

Step through an unsafe merge
Step 0 of 10Ready
Your turn: follow the blue line

Step through how a naive deep merge turns an own __proto__ key into an inherited default.

Running in
  1. script
Next: line 15
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
function isObjectLike(value) {  return value !== null && (typeof value === "object" || typeof value === "function");}function unsafeMerge(target, source) {  for (const key of Object.keys(source)) {    const value = source[key];    if (isObjectLike(value) && isObjectLike(target[key])) {      unsafeMerge(target[key], value);    } else {      target[key] = value;    }  }  return target;}const target = Object.create(sandboxPrototype);const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');unsafeMerge(target, payload);const fresh = Object.create(sandboxPrototype);console.log(fresh.isAdmin);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Run the merge in an isolated iframe realm
Merge used by the iframe runPop out in the code editor (opens in a new tab)JavaScript
function isObjectLike(value) {  return value !== null && (typeof value === "object" || typeof value === "function");}function unsafeMerge(target, source) {  for (const key of Object.keys(source)) {    const value = source[key];    if (isObjectLike(value) && isObjectLike(target[key])) {      unsafeMerge(target[key], value);    } else {      target[key] = value;    }  }  return target;}const sandboxPrototype = {};const target = Object.create(sandboxPrototype);const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');unsafeMerge(target, payload);const fresh = Object.create(sandboxPrototype);console.log(fresh.isAdmin);
Iframe resultunsafe
Fresh object beforenot run
Fresh object after mergenot run
Target owns __proto__?not run
After cleanupnot run
Try it yourself
Merge helper

Choose a merge and run it inside a separate iframe realm. The lesson page's own Object.prototype is not touched.

The iframe has its own Object.prototype. The demo deletes the key in finally and removes the iframe after each run.

Unsafe deep merges and path setters

ATTACK SHAPES

The vulnerable pattern is small: obj[key] = value or merge(target[key], value) while key comes from input. A single __proto__ key is one route. The nested constructor.prototype route is another: a naive merge may follow inherited constructor to Object, then prototype to Object.prototype.

Path setter pollutionPop out in the code editor (opens in a new tab)JavaScript
function setByPath(target, path, value) {  const parts = path.split(".");  let current = target;  for (const part of parts.slice(0, -1)) {    if (current[part] == null) current[part] = {};    current = current[part];  }  current[parts.at(-1)] = value;  return target;} try {  setByPath({}, "a.__proto__.polluted", true);  console.log(({}).polluted);} finally {  delete Object.prototype.polluted;}

Set-by-path helpers, form parsers, and query-string parsers often turn text into segments. A URL shape such as ?__proto__[isAdmin]=1 or a path such as a.__proto__.polluted is dangerous if the parser builds objects by assignment without filtering each segment.

Constructor-prototype routePop out in the code editor (opens in a new tab)JavaScript
function isObjectLike(value) {  return value !== null && (typeof value === "object" || typeof value === "function");}function unsafeMerge(target, source) {  for (const key of Object.keys(source)) {    const value = source[key];    if (isObjectLike(value) && isObjectLike(target[key])) {      unsafeMerge(target[key], value);    } else {      target[key] = value;    }  }} try {  unsafeMerge({}, JSON.parse('{"constructor":{"prototype":{"polluted":"yes"}}}'));  console.log(({}).polluted);} finally {  delete Object.prototype.polluted;}

Blocking only __proto__ is incomplete. A safe merge treats __proto__, constructor, and prototype as dangerous at every depth, before it reads from the target.

Block dangerous keys early

STEP THROUGH

A hardened merge does three boring things. First, it rejects dangerous keys and path segments before any target lookup. Second, it treats untrusted input as schema-validated data, not as arbitrary object shape. Third, it reads security decisions with own-property checks.

Step through a safe merge
Step 0 of 8Ready
Your turn: follow the blue line

Step through the same payload being blocked by a key guard and own-property reads.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
function isPlainObject(value) {  return value !== null && typeof value === "object" && !Array.isArray(value);}function safeMerge(target, source) {  for (const key of Object.keys(source)) {    if (dangerousKeys.has(key)) continue;    const value = source[key];    if (isPlainObject(value)) {      const current = Object.hasOwn(target, key) ? target[key] : undefined;      target[key] = safeMerge(isPlainObject(current) ? current : {}, value);    } else {      target[key] = value;    }  }  return target;}const sandboxPrototype = {};const target = Object.create(sandboxPrototype);const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');safeMerge(target, payload);const fresh = Object.create(sandboxPrototype);console.log(fresh.isAdmin);console.log(Object.hasOwn(target, "__proto__"));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Safe path setter blocks before walkingPop out in the code editor (opens in a new tab)JavaScript
const dangerousKeys = new Set(["__proto__", "constructor", "prototype"]);function safeSetByPath(target, path, value) {  const parts = path.split(".");  if (parts.some((part) => dangerousKeys.has(part))) return false;  let current = target;  for (const part of parts.slice(0, -1)) {    if (!Object.hasOwn(current, part) || typeof current[part] !== "object") current[part] = {};    current = current[part];  }  current[parts.at(-1)] = value;  return true;} const target = {};console.log(safeSetByPath(target, "a.__proto__.polluted", true));console.log(({}).polluted);
Safe key handling or vulnerable?
  • `target[key] = value` when `key` comes from a request body
  • A deep merge reads `target[key]` before checking whether `key` is dangerous
  • `set(obj, "a.__proto__.polluted", true)`
  • Skip `__proto__`, `constructor`, and `prototype` before reading the target
  • Read permission flags with `Object.hasOwn(config, "isAdmin")`
  • Store user-supplied keys in a `Map`
  • Freeze `Object.prototype` in a tightly controlled runtime
  • Run Node with `--disable-proto=throw`
Try it yourself
0 of 8 correct

Sort each practice by whether it is vulnerable, a safer default, or defense in depth. Read the explanations because order matters.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

structuredClone can copy data without invoking setters on the target because it creates a fresh clone, but it is not validation. A JSON reviver can drop dangerous keys while parsing. Schema validation should reject unknown fields before they reach merge or path code.

Object.create(null) and Map

DATA STRUCTURES

Sometimes the safest fix is to stop using a plain object as an open-ended dictionary. A null-prototype object has no inherited toString, no inherited constructor, and no inherited __proto__ accessor. That means the dangerous-looking names are just own keys. The trade-off is also real: there is no inherited toString or hasOwnProperty, so use static helpers such as Object.entries and Object.hasOwn.

Null-prototype dictionaryPop out in the code editor (opens in a new tab)JavaScript
const dict = Object.create(null);dict["__proto__"] = "kept as data";dict.constructor = "also data"; console.log(dict["__proto__"]);console.log("toString" in dict);console.log(JSON.stringify(dict));

A Map is often clearer for user-supplied key/value data. It does not use property lookup for entries, accepts non-string keys, has get, set, has, delete, and size, and keeps insertion order explicit.

Map keeps dangerous-looking names as dataPop out in the code editor (opens in a new tab)JavaScript
const flags = new Map();flags.set("__proto__", "kept as data");flags.set("constructor", "also data"); console.log(flags.get("__proto__"));console.log(({}).polluted);
Plain object vs null-prototype vs Map vs frozen prototype
ChoiceUse it forCaution
Plain object {}Known fields controlled by your codeInherited names exist; __proto__ assignment has legacy behavior; use Object.hasOwn for decisions.
Object.create(null)String-key dictionaries whose keys come from usersNo inherited keys and no toString or hasOwnProperty; use static Object helpers.
MapKey/value collections with arbitrary user keys or non-string keysNot JSON by default; convert entries deliberately for storage or network boundaries.
Frozen Object.prototypeLocked-down apps that own their whole runtimeDefense in depth only; compatibility risk and not a replacement for safe merge code.

Freezing prototypes and Node flags

Object.freeze(Object.prototype) can make direct pollution attempts fail because the shared prototype is no longer extensible or writable for its data properties. Some locked-down apps do this very early, before application code runs. The risk is compatibility: old libraries, polyfills, tests, or instrumentation that patch built-in prototypes may break. Freezing is defense in depth, not a substitute for safe merge code.

Node's --disable-proto flag

Node documents --disable-proto=delete and --disable-proto=throw. delete removes Object.prototype.__proto__; throw makes accesses throw ERR_PROTO_ACCESS. The flag narrows the __proto__ route, but it does not validate input or remove the constructor.prototype route.

Node runtime flagsBash
node --disable-proto=delete server.jsnode --disable-proto=throw server.js

Use runtime flags and frozen prototypes only when you control compatibility. Keep the main fixes in code: schema validation, filtered keys, own-property reads, null-prototype dictionaries, and Map where a map is the real data model.

Impact and real CVEs

HISTORY

Prototype pollution impact depends on what the application reads later. The simplest bug is authorization bypass: code checks if (user.isAdmin) and a polluted default supplies true. Denial of service can happen when polluted methods, types, or options make code throw or recurse. Gadget chains are worse: a polluted option may flow into a template, sanitizer, command runner, or server-side framework and become XSS or remote code execution. Treat those as conceptual risks and audit each gadget path rather than assuming every pollution is instantly exploitable.

Prototype pollution advisories verified from public CVE records
LibraryCVE and yearPattern
lodash merge, mergeWith, defaultsDeepCVE-2018-3721 (2018), CVE-2018-16487 (2018)Recursive merge copied __proto__ / constructor-shaped input into prototypes.
jQuery $.extend(true, ...)CVE-2019-11358 (2019)Deep extend could write attacker-controlled properties onto Object.prototype.
minimistCVE-2020-7598 (2020)Command-line parsing accepted prototype paths from arguments.
qsCVE-2022-23529 (2022)Query-string parsing had prototype pollution paths in nested parameters.

Citation trail: NVD CVE-2018-3721 and GitHub Advisory CVE-2018-16487 for lodash merge/defaultsDeep, NVD CVE-2019-11358 for jQuery.extend, NVD CVE-2020-7598 for minimist, and GitHub Advisory CVE-2022-23529 for qs.

Common misconceptions

  • “JSON.parse pollutes by itself.” No. It creates an own __proto__ data property. Assignment during merge is the dangerous step.
  • “Block only __proto__.” Also block constructor and prototype at every depth.
  • “A null-prototype object is always better.” It is better for dictionaries with untrusted keys, but plain records with known fields remain readable and natural.
  • “Freezing prototypes fixes the app.” It is a guardrail with compatibility costs. Unsafe merge code still needs to be fixed.
  • “CVE means every app using the package had RCE.” The library bug creates the primitive. Exploitability depends on reachable input and useful gadget code.
  • “Checking `config.isAdmin` is fine because the config is small.” Security decisions should require own data or validated defaults.
Similar defenses that are easy to confuse
DefenseProtects againstDoes not replace
Key filteringKnown prototype traversal names in merge/set helpersSchema validation and own-property reads
Schema validationUnexpected fields before they reach business logicSafe code in lower-level merge helpers
Object.hasOwnInherited defaults influencing decisionsCleaning already-polluted prototypes
Object.create(null) / MapInherited keys in dictionariesValidating values and allowed fields

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upProve JSON owns the key

Predict the three console lines.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const parsed = JSON.parse('{"__proto__":{"isAdmin":true}}');
console.log(Object.hasOwn(parsed, "__proto__"));
console.log(Object.getPrototypeOf(parsed) === Object.prototype);
console.log(({}).isAdmin);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeTrace the unsafe merge

    What does the final pollution check print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    function isObjectLike(value) {
      return value !== null && (typeof value === "object" || typeof value === "function");
    }
    function unsafeMerge(target, source) {
      for (const key of Object.keys(source)) {
        const value = source[key];
        if (isObjectLike(value) && isObjectLike(target[key])) {
          unsafeMerge(target[key], value);
        } else {
          target[key] = value;
        }
      }
      return target;
    }
    
    const parsed = JSON.parse('{"__proto__":{"isAdmin":true}}');
    console.log(Object.hasOwn(parsed, "__proto__"));
    console.log(Object.getPrototypeOf(parsed) === Object.prototype);
    console.log(({}).isAdmin);
    try {
      unsafeMerge({}, parsed);
      console.log(({}).isAdmin);
    } finally {
      delete Object.prototype.isAdmin;
    }

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeConfirm the safe merge blocks it

      Type the two printed values.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const dangerousKeys = new Set(["__proto__", "constructor", "prototype"]);
      function isPlainObject(value) {
        return value !== null && typeof value === "object" && !Array.isArray(value);
      }
      function safeMerge(target, source) {
        for (const key of Object.keys(source)) {
          if (dangerousKeys.has(key)) continue;
          const value = source[key];
          if (isPlainObject(value)) {
            const current = Object.hasOwn(target, key) ? target[key] : undefined;
            target[key] = safeMerge(isPlainObject(current) ? current : {}, value);
          } else {
            target[key] = value;
          }
        }
        return target;
      }
      const sandboxPrototype = {};
      const target = Object.create(sandboxPrototype);
      const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');
      safeMerge(target, payload);
      const fresh = Object.create(sandboxPrototype);
      console.log(fresh.isAdmin);
      console.log(Object.hasOwn(target, "__proto__"));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeChoose storage for user keys

        Which built-in keyed collection keeps __proto__ as data instead of a property accessor?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFix an inherited permission flag

          A polluted default makes config.isAdmin truthy. Which expression proves the flag is owned by config?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          const defaults = { isAdmin: true };
          const requestConfig = Object.create(defaults);
          
          console.log(requestConfig.isAdmin);
          console.log(Object.hasOwn(requestConfig, "isAdmin"));

          Answer, then press Check. Spacing and letter case don’t matter.

            Quiz: check your understanding

            8 QUESTIONS

            For each question, name the object that receives the write, then ask whether later code reads own data or inherited data.

            Prototype pollution quiz · 8 questionsScore: first tries count
            1. Question 1 of 8What is prototype pollution?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does JSON.parse do with an __proto__ member?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const parsed = JSON.parse('{"__proto__":{"isAdmin":true}}');
              console.log(Object.hasOwn(parsed, "__proto__"));
              console.log(Object.getPrototypeOf(parsed) === Object.prototype);

              Choose an answer to see the explanation.

            3. Question 3 of 8What does the unsafe merge proof print at the end?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function isObjectLike(value) {
                return value !== null && (typeof value === "object" || typeof value === "function");
              }
              function unsafeMerge(target, source) {
                for (const key of Object.keys(source)) {
                  const value = source[key];
                  if (isObjectLike(value) && isObjectLike(target[key])) {
                    unsafeMerge(target[key], value);
                  } else {
                    target[key] = value;
                  }
                }
                return target;
              }
              
              const parsed = JSON.parse('{"__proto__":{"isAdmin":true}}');
              console.log(Object.hasOwn(parsed, "__proto__"));
              console.log(Object.getPrototypeOf(parsed) === Object.prototype);
              console.log(({}).isAdmin);
              try {
                unsafeMerge({}, parsed);
                console.log(({}).isAdmin);
              } finally {
                delete Object.prototype.isAdmin;
              }

              Choose an answer to see the explanation.

            4. Question 4 of 8Which payload can reach Object.prototype even when __proto__ is blocked?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function isObjectLike(value) {
                return value !== null && (typeof value === "object" || typeof value === "function");
              }
              function unsafeMerge(target, source) {
                for (const key of Object.keys(source)) {
                  const value = source[key];
                  if (isObjectLike(value) && isObjectLike(target[key])) {
                    unsafeMerge(target[key], value);
                  } else {
                    target[key] = value;
                  }
                }
              }
              
              try {
                unsafeMerge({}, JSON.parse('{"constructor":{"prototype":{"polluted":"yes"}}}'));
                console.log(({}).polluted);
              } finally {
                delete Object.prototype.polluted;
              }

              Choose an answer to see the explanation.

            5. Question 5 of 8Why should permission checks use own-property reads?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const defaults = { isAdmin: true };
              const requestConfig = Object.create(defaults);
              
              console.log(requestConfig.isAdmin);
              console.log(Object.hasOwn(requestConfig, "isAdmin"));

              Choose an answer to see the explanation.

            6. Question 6 of 8What does a null-prototype dictionary prove?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const dict = Object.create(null);
              dict["__proto__"] = "kept as data";
              dict.constructor = "also data";
              
              console.log(dict["__proto__"]);
              console.log("toString" in dict);
              console.log(JSON.stringify(dict));

              Choose an answer to see the explanation.

            7. Question 7 of 8What does Map do with dangerous-looking key names?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const flags = new Map();
              flags.set("__proto__", "kept as data");
              flags.set("constructor", "also data");
              
              console.log(flags.get("__proto__"));
              console.log(({}).polluted);

              Choose an answer to see the explanation.

            8. Question 8 of 8What is the honest role of freezing Object.prototype?

              Choose an answer to see the explanation.

            Key takeaways

            • Prototype pollution is a property-write bug that turns input keys into inherited defaults.
            • JSON.parse creates an own __proto__ data property; unsafe assignment or deep merge makes it dangerous.
            • Block __proto__, constructor, and prototype before any target lookup in merge and path helpers.
            • Use Object.hasOwn for security decisions, and prefer Object.create(null) or Map for user-key dictionaries.
            • Freezing prototypes and Node's --disable-proto flags are defense in depth with compatibility limits.

            Remember the one-liner.
            Do not let untrusted keys decide where your object graph writes; validate, filter, and read only the data you own.

            Up next: supply-chain security, where the same defensive mindset moves from object keys to the packages you install.

            CompleteFrontend Clear concepts. Working examples.