completefrontend
Open lab My progress
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
    Follow the pollution pathShow how the keys __proto__ and constructor.prototype turn input data into a write on a shared prototype.
  • 02
    Make merges and path helpers safeSkip dangerous keys before reading the target, and check for own properties before making a decision.
  • 03
    Pick a safer place for keysCompare plain objects, null-prototype objects, Map, frozen prototypes, input checks, and Node flags.

Prototype pollution in plain words

Most JavaScript objects have a prototype. A prototype is another object that JavaScript checks when an object doesn't have a property itself. Many objects can share one prototype.

Prototype pollution is a bug where outside data adds a property to a shared prototype. It happens in three steps:

  • Input from a user contains a special key, such as __proto__, constructor, or prototype.
  • A helper copies that input into an object. It treats the special key like any other key.
  • The copy lands on the shared prototype. Now every object that uses that prototype seems to have the new property.

The helper might be a merge (code that copies keys from one object into another), a parser, or a function that sets a value by its path, like "a.b.c".

In one sentence: copying keys from outside data into objects can change every object that shares the polluted prototype. This lesson uses ideas from 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 outside data decides where a property is written, and the write lands on a prototype. After that, objects read a value that nobody set on them.

Real-life analogyA shared apartment noticeboard

Picture an apartment building. Each door has its own notes. Everyone also reads one shared noticeboard in the hall.

Someone pins a fake notice on the board: "All residents are staff." Now every apartment without its own note seems to be staff. Nobody changed those doors. One notice on the shared board did it.

In real life: A private note on one door
In JavaScript: A property that one object has itself (an own property)
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 value on the prototype, like isAdmin: true
In real life: Checking the tenant's own ID
In JavaScript: Object.hasOwn(user, "isAdmin")
Safety of this lesson's demos

Some examples add a property to Object.prototype on purpose. They run in a separate, fresh page (an iframe) or a fresh test context. Each one deletes the property again in a finally block, which runs even if the code fails. The step-through demos use their own small prototype object, so this page's real Object.prototype never changes.

Why one prototype reaches many objects

FOUNDATION

When you read a property, JavaScript looks in two places, in order:

  1. First it looks on the object itself.
  2. If the property is not there, it looks on the object's prototype, then that prototype's prototype, and so on. This line of objects is the prototype chain.

Almost every plain object ends its chain at Object.prototype. So if a property is added to Object.prototype, an empty object like {} can read it too. A property found this way is inherited. A property the object has itself is an own property.

Two empty objects, a and b, both point to one shared prototype. The prototype holds isAdmin: true, so a.isAdmin and b.isAdmin both give true.isAdmin: trueprototypeone shared objectaa.isAdmin → truebb.isAdmin → true
a and b own nothing, so JavaScript looks in the prototype they share. One value written there shows up on both.

The special key __proto__ makes this risky. It is an old accessor: a property that runs code when you read or write it. It lives on Object.prototype, so plain objects inherit it.

  • Reading obj["__proto__"] gives back the object's prototype.
  • Writing obj["__proto__"] = other changes that one object's prototype to other.
  • A deep merge (a merge that also merges objects nested inside) is worse. It can read the prototype and then write new properties into it.

The next example shows why an inherited value is a problem for a permission check.

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 makes a defaults object with isAdmin: true. Line 2 uses Object.create(defaults) to make requestConfig, a new empty object whose prototype is defaults. Line 4 prints true, because the read finds isAdmin on the prototype. Line 5 asks Object.hasOwn(requestConfig, "isAdmin"): "does this object have isAdmin itself?" It prints false.

So a plain read can't tell you where a value came from. For permission checks and settings, check that the property is the object's own.

The in operator doesn't help here. It also looks along the prototype chain, so it can't tell own from inherited. Line 2 finds role on the prototype and prints true. Line 3 checks only the object itself and prints false.

in finds inherited properties tooPop out in the code editor (opens in a new tab)JavaScript
const config = Object.create({ role: "admin" });console.log("role" in config);console.log(Object.hasOwn(config, "role"));

JSON is safe until a merge copies it

STEP THROUGH

Attack input often arrives as JSON text. JSON.parse turns that text into an object. When the text has a key named "__proto__", JSON.parse does not run the __proto__ accessor. It just makes a normal own property whose name is "__proto__".

So the parsed object is not polluted. The danger starts later, when a merge copies that key into another object. The example below has two parts:

  • Lines 1 to 14 define unsafeMerge, a deep merge with no safety checks.
  • Lines 16 to 25 parse the input, check it, merge it, and clean up.
JSON.parse makes a normal key named __proto__Pop 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;}

Here is what each highlighted line does:

  • Line 16 parses the text into parsed.
  • Line 17 prints true: parsed has its own key named __proto__.
  • Line 18 prints true: the prototype of parsed is still the normal Object.prototype.
  • Line 19 prints undefined: an empty object has no isAdmin yet.
  • Line 21 runs unsafeMerge. It follows the __proto__ key into Object.prototype and writes isAdmin: true there.
  • Line 22 prints true: now every empty object reads isAdmin from the prototype.
  • Line 24, in the finally block, deletes the property again.
Left: JSON.parse makes an object called parsed with an own key named __proto__. An arrow labelled unsafeMerge leads to the prototype, which now holds isAdmin: true, so ({}).isAdmin gives true.JSON.parse(…)__proto__: {…}parsedan own keyunsafeMergeisAdmin: trueprototype({}).isAdmin → true
JSON.parse only makes a normal key named __proto__. The unsafe merge is the step that writes isAdmin into the shared prototype.

Object.assign is a related trap. It copies each key with a normal write, like target[key] = value. A normal write of __proto__ runs the accessor, so it changes the prototype of target. It does not change Object.prototype. Here is what happens:

  • Line 4 copies the input into target.
  • Line 5 prints true, and line 6 prints false: target can read isAdmin, but it doesn't own it.
  • Line 7 prints false: the prototype of target is no longer Object.prototype.
  • Line 8 prints undefined: other plain objects are not affected.
  • Line 10, in finally, puts the normal prototype back, so line 12 prints undefined.
Object.assign can change its target's prototypePop out in the code editor (opens in a new tab)JavaScript
const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');const target = {};try {  Object.assign(target, payload);  console.log(target.isAdmin);  console.log(Object.hasOwn(target, "isAdmin"));  console.log(Object.getPrototypeOf(target) === Object.prototype);  console.log(({}).isAdmin);} finally {  Object.setPrototypeOf(target, Object.prototype);}console.log(target.isAdmin);

Now step through an unsafe merge one line at a time. This version uses its own small prototype object, called sandboxPrototype, instead of the real Object.prototype.

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

Step through how an unsafe merge turns a __proto__ key into a value that every object reads.

Running in
  1. script
Next: line 15
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);
CallStoreChangeResultBlue line: runs next. 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.

In the playground, pick a merge and run it. It runs inside a hidden iframe, which has its own Object.prototype, so this page stays clean.

Run a merge in a hidden iframe
The merge that runs in the iframePop 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);
What the iframe sawunsafe
Empty object's isAdmin, beforenot run
Empty object's isAdmin, after the mergenot run
Did the target get its own __proto__ key?not run
isAdmin after the clean-upnot run
Try it yourself
Which merge

Pick a merge and run it. It runs inside a hidden iframe, so this page's own Object.prototype never changes.

The iframe has its own Object.prototype. After each run, the demo deletes the property and removes the iframe.

Unsafe deep merges and path setters

ATTACK SHAPES

The unsafe code is usually small. It is one of these two lines, where key comes from the input:

  • obj[key] = value, a write with a key chosen by the input.
  • merge(target[key], value), a deep merge into whatever target[key] gives back.

A path setter is a helper that sets a nested value from a text path. For example, setByPath(obj, "a.b", 1) sets obj.a.b to 1. It splits the path at each dot into segments ("a", "b") and walks them one by one.

A path setter writes into the prototypePop 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;}

Line 13 calls setByPath with the path "a.__proto__.polluted". Here is the walk:

  1. Segment a: nothing is there yet, so line 5 makes a new empty object, and line 6 moves into it.
  2. Segment __proto__: reading it gives back that object's prototype, Object.prototype. It isn't missing, so line 5 makes nothing new, and line 6 moves into it.
  3. Segment polluted: line 8 writes true onto Object.prototype.

Line 14 prints true for an empty object. Line 16 deletes the property again.

Form parsers and URL query parsers often build objects the same way. A URL like ?__proto__[isAdmin]=1 or a path like a.__proto__.polluted is dangerous if the parser writes each segment without checking it.

Brackets and dots can name the same property: profile[__proto__] means the same as profile.__proto__. So turn the path into a clean list of segments first, then check each segment. This is called normalizing the path. The next example only checks a path; it never writes to an object.

  • Line 1 starts with a bracket-style path for a profile field.
  • Lines 2 to 5 turn each [ into a dot, remove each ], split at the dots, and drop empty parts.
  • Line 7 prints the normalized path, profile.__proto__.isAdmin.
  • Line 8 prints true: one segment is a blocked key, so the code can refuse before any write.
Normalize the path, then check each segmentPop out in the code editor (opens in a new tab)JavaScript
const parts = "profile[__proto__][isAdmin]"  .replaceAll("[", ".")  .replaceAll("]", "")  .split(".")  .filter(Boolean);const dangerousKeys = new Set(["__proto__", "constructor", "prototype"]);console.log(parts.join("."));console.log(parts.some((part) => dangerousKeys.has(part)));

Blocking only __proto__ is not enough. A second route uses constructor and then prototype:

  • Every plain object inherits a constructor property. It points to the Object function.
  • Object.prototype is the prototype property of that function.
  • So the input {"constructor":{"prototype":{...}}} leads a careless deep merge straight to Object.prototype, if the merge also walks into functions. The isObjectLike check on line 2 lets it do that.
The 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;}

Line 16 merges that input into an empty object. Line 17 prints yes: an empty object now reads polluted from Object.prototype. Line 19 deletes it again.

So a safe merge treats all three names as dangerous: __proto__, constructor, and prototype. It checks for them at every level of nesting, before it reads anything from the target.

Block dangerous keys early

STEP THROUGH

A safe merge does three simple things:

  1. It skips the dangerous keys (__proto__, constructor, prototype) before it reads anything from the target.
  2. It only accepts input fields you expect. Checking input against a list of allowed fields and types is called schema validation.
  3. When a value decides something important, it reads only own properties.

Step through the safe merge below. It gets the same input as the unsafe merge. Line 7 checks the key first. The key is __proto__, so the merge skips it.

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

Step through the same input being skipped by a safe merge.

Running in
  1. script
Next: line 1
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__"));
CallStoreChangeResultBlue line: runs next. 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.
Top: unsafeMerge follows the key __proto__ into the prototype, which gets isAdmin: true, so fresh.isAdmin is true. Bottom: safeMerge skips the key __proto__, the prototype stays empty, and fresh.isAdmin is undefined.unsafeMerge__proto__isAdmin: trueprototypefresh.isAdmintruesafeMerge__proto__skippedprototypefresh.isAdminundefined
Both get the same __proto__ key. unsafeMerge follows it into the prototype; safeMerge checks the key first and skips it.

A path setter needs the same guard. In the next example, line 4 checks every segment before the walk starts. Line 15 prints false: the setter refused. Line 16 prints undefined: no object was polluted.

A safe path setter checks before it walksPop 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 unsafe?
  • `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-chosen keys in a `Map`
  • Freeze `Object.prototype` in an app where you control all the code
  • Run Node with `--disable-proto=throw`
Try it yourself
0 of 8 correct

Sort each habit into one group: unsafe, a safer default, or an extra layer of protection. Read each explanation after you sort.

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

A few other tools can help, but none of them checks the input for you:

  • structuredClone makes a fresh copy of data. It doesn't run the target's accessors, but it doesn't remove bad keys either.
  • A reviver is a function you pass to JSON.parse. It can drop dangerous keys while the text is parsed.
  • Schema validation should reject unknown fields before they reach any merge or path code.

A profile form should never choose its own permission fields. The next example uses an allowlist: a list of the only fields you accept.

  • Line 1 parses input that tries to send isAdmin and __proto__.
  • Line 2 allows only displayName.
  • Line 3 makes the result with Object.create(null): an object with no prototype at all.
  • Lines 4 to 8 copy a field only if it is allowed and its value is a string.
  • Line 9 prints Asha. Line 10 prints false: isAdmin was not copied. Line 11 prints undefined: no prototype changed.

The allowlist guards the entry point. Lower-level merge and path helpers still need their own key checks, because data can reach them by other routes.

Copy only allowed profile fieldsPop out in the code editor (opens in a new tab)JavaScript
const input = JSON.parse('{"displayName":"Asha","isAdmin":true,"__proto__":{"isAdmin":true}}');const allowedFields = new Set(["displayName"]);const profile = Object.create(null);for (const key of Object.keys(input)) {  if (allowedFields.has(key) && typeof input[key] === "string") {    profile[key] = input[key];  }}console.log(profile.displayName);console.log(Object.hasOwn(profile, "isAdmin"));console.log(({}).isAdmin);

Object.create(null) and Map

DATA STRUCTURES

Sometimes the safest fix is to stop using a plain object to store keys that users choose. Two other containers keep those keys as plain data.

The first is a null-prototype object, made with Object.create(null). It has no prototype, so it inherits nothing:

  • No inherited __proto__ accessor, so __proto__ is just a normal key.
  • No inherited constructor, so that is just a normal key too.
  • No inherited toString or hasOwnProperty. Use the helpers on Object instead, such as Object.entries and Object.hasOwn.
A null-prototype object keeps every key as dataPop 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));

Line 1 makes dict with no prototype. Lines 2 and 3 store two values. Line 5 prints kept as data. Line 6 prints false: there is no toString to find. Line 7 prints both keys as JSON.

The second is a Map. A Map stores entries (key and value pairs) apart from its properties, so a key never reaches a prototype. It also has more to offer:

  • Keys can be any value, not just strings.
  • It has clear methods: get, set, has, delete, and a size property.
  • It keeps entries in the order you added them.
A 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);

Line 5 prints kept as data. Line 6 prints undefined: no prototype changed.

Left: an object made with Object.create(null) holds __proto__ as an own key. Right: a Map holds __proto__ as an entry. Neither one touches a prototype.Object.create(null)__proto__: …dictan own keynew Map()__proto__: …flagsa Map entry
In both containers, __proto__ is just a name with a value stored under it. No prototype is reached or changed.

A Map has one catch when you send data as JSON: JSON.stringify skips its entries. Line 1 stores a user key. Line 2 reads its value. Line 3 prints {}, an empty object. When you need JSON, convert the entries yourself. When the JSON comes back, check it again.

A Map needs its own JSON conversionPop out in the code editor (opens in a new tab)JavaScript
const flags = new Map([["__proto__", "kept as data"]]);console.log(flags.get("__proto__"));console.log(JSON.stringify(flags));

The table compares four choices. Each row says what the choice is good for and what to watch out for.

Where to keep keys: plain object, null-prototype object, Map, or frozen prototype
What you useWhen it fitsWhat to watch out for
A plain object {}Use it for fields your own code knows about.It inherits names like constructor, and writing __proto__ runs the old accessor. Use Object.hasOwn for decisions.
Object.create(null)Use it for string keys that users choose.It inherits nothing, not even toString or hasOwnProperty. Use the helpers on Object instead.
A MapUse it for any keys that users choose, including keys that aren't strings.JSON.stringify skips its entries. Convert them yourself before you save or send them.
A frozen Object.prototypeUse it in a locked-down app where you control all the code.It is only an extra layer. It can break other code, and it doesn't replace a safe merge.

Freezing prototypes and Node flags

Object.freeze(Object.prototype) locks the shared prototype. After that, nobody can add new properties to it or change its normal values, so direct pollution attempts fail. Some locked-down apps do this at the very start, before any other code runs.

The risk is that other code may stop working. Old libraries, polyfills (code that adds missing built-in features), tests, and monitoring tools sometimes change built-in prototypes on purpose. Freezing is an extra layer of protection. It does not replace a safe merge.

Node's --disable-proto flag

Node can start with a --disable-proto flag. It has two modes:

  • --disable-proto=delete removes Object.prototype.__proto__.
  • --disable-proto=throw makes every use of it throw an ERR_PROTO_ACCESS error.

Both close the __proto__ route. Neither one checks your input, and neither one closes the constructor.prototype route.

Start Node without the __proto__ accessorBash
node --disable-proto=delete server.jsnode --disable-proto=throw server.js

Line 1 starts server.js in delete mode. Line 2 starts it in throw mode.

Use frozen prototypes and Node flags only when you know all your code still works with them. Keep the main fixes in your own code:

  • Check input against the fields you allow.
  • Skip the dangerous keys.
  • Read own properties for important decisions.
  • Store user-chosen keys in a null-prototype object or a Map.

Impact and real CVEs

HISTORY

How much harm pollution does depends on what the app reads later. Here are the main kinds of harm, from simplest to worst:

  • Skipping a permission check. The code checks if (user.isAdmin), and a polluted value gives true.
  • Denial of service. A polluted value makes code crash or loop forever, so the app stops working for everyone.
  • A chain into worse bugs. A polluted setting flows into other code, such as a template, an HTML cleaner, or a command runner. This could lead to XSS, or to an attacker running commands on the server.

These are possible risks, not certain ones. For each case, follow the polluted value and see what code actually reads it.

A CVE is a public ID number for a known security bug. The table lists real prototype pollution bugs in popular libraries. Each row gives the library, the CVE number and year, and what the bug did.

Real prototype pollution bugs, checked against public CVE records
Which libraryCVE number and yearWhat went wrong
lodash merge, mergeWith, defaultsDeepCVE-2018-3721 (2018), CVE-2018-16487 (2018)Its deep merge copied __proto__ and constructor keys from input into prototypes.
jQuery $.extend(true, ...)CVE-2019-11358 (2019)Its deep copy could write properties chosen by an attacker onto Object.prototype.
minimistCVE-2020-7598 (2020)It read command-line arguments and accepted paths that reached a prototype.
qsCVE-2022-24999 (2022)Crafted nested query parameters could hang a Node process (denial of service).

Sources: 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 NVD CVE-2022-24999 for qs.

To check your own app:

  1. Find where keys from requests enter your code.
  2. Find what important code reads those values later.
  3. Check the versions of the packages you use, and the packages they use.
  4. Update any affected package to a fixed version that is still supported.
  5. Test your parser settings.

A CVE in a package you use does not prove your app has a permission bypass. It doesn't prove attackers can run commands either. It means you should check.

Common misconceptions

  • "JSON.parse pollutes by itself." No. It makes a normal own key named __proto__. The merge that copies it is the dangerous step.
  • "Blocking __proto__ is enough." No. Also block constructor and prototype, at every level of nesting.
  • "A null-prototype object is always better." No. It is best for keys that users choose. For objects with fields your code knows, a plain object is easier to read.
  • "Freezing prototypes fixes the app." No. It is an extra layer that can break other code. The unsafe merge still needs fixing.
  • "A CVE means every app using the package can be taken over." No. The package bug lets pollution happen. Real harm depends on whether attackers can reach it and what code reads the polluted value.
  • "Checking config.isAdmin is fine because the config is small." No. Size doesn't matter. Important decisions should read own properties or checked defaults.

These four defenses are easy to mix up. Each row says what one defense stops and what it can't do alone.

Four defenses that are easy to confuse
Which defenseWhat it stopsWhat it doesn't replace
Skipping dangerous keysIt stops merge and path helpers from following __proto__, constructor, or prototype.It doesn't replace checking fields or reading own properties.
Schema validationIt stops unexpected fields before they reach the rest of your code.It doesn't replace key checks inside merge and path helpers.
Object.hasOwnIt stops inherited values from deciding a permission or setting.It doesn't clean a prototype that is already polluted.
Object.create(null) or MapIt stops user-chosen keys from reaching a prototype.It doesn't check the values or which fields are allowed.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upShow that JSON keeps the key as data

Guess the three lines the console prints.

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 · PracticeFollow the unsafe merge

    What does the last isAdmin 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 · PracticeCheck that the safe merge stops it

      Type the two values the console prints.

      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 · PracticePick a place for user keys

        Which built-in container keeps a key named __proto__ as plain data, not as an accessor?

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

          Exercise 5 · ChallengeFix a permission check that trusts inherited values

          A polluted prototype makes config.isAdmin true. Which expression checks that config has isAdmin itself?

          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, first find which object gets the write. Then ask: does the later read find an own value or an inherited one?

            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 8JSON.parse reads a __proto__ key. What does this code print?

              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 last console.log in this unsafe merge example print?

              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 8This input uses constructor instead of __proto__. What does it print?

              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 8requestConfig gets isAdmin from its prototype. What does this code print?

              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 this null-prototype object print?

              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 this Map example print?

              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 freezing Object.prototype good for?

              Choose an answer to see the explanation.

            Key takeaways

            • Prototype pollution is a bug where a key from the input makes a write land on a shared prototype. Then many objects read the new value.
            • JSON.parse makes a normal own key named __proto__. It becomes dangerous only when an unsafe write or deep merge copies it.
            • In merge and path helpers, skip __proto__, constructor, and prototype before reading anything from the target.
            • Use Object.hasOwn for important decisions. Store user-chosen keys in Object.create(null) or a Map.
            • Frozen prototypes and Node's --disable-proto flag are extra layers. They can break other code, and they don't replace safe helpers.

            Remember the one-liner.
            Never let keys from the input decide where your code writes. Check the input, skip dangerous keys, and trust only properties an object owns.

            Up next: supply-chain security. It is about the same kind of care, but for the packages you install instead of the keys you copy.

            CompleteFrontend Clear concepts. Working examples.