skip to content

Why does a single assignment such as `Object.prototype.isAdmin = true` make `({}).isAdmin` return true even for objects created much earlier, and what does that imply for code that copies keys from untrusted JSON into an object?

level: seniorimportance: should knowfreq 40%

answer

  1. links are live, not snapshots
  2. one shared object at the end of every chain
  3. reads resolve at access time
  4. names are how the chain is navigated
  5. null-prototype or Map for untrusted keys

basics

~20 s

Almost every object links to Object.prototype, and lookups resolve through that link on each read rather than at creation time, so one added property becomes visible everywhere immediately. Code that copies untrusted keys can therefore change behaviour for objects it never touched.

solid answer

~50 s

Ordinary objects end their prototype chain at `Object.prototype`, and the chain is walked on every read, not snapshotted at construction. Adding a property there means every object that does not own or shadow that name now resolves it — including objects created long before the write, since they hold a live link rather than a copy. That is why one write has global reach, and why a `key in obj` or truthiness check on a plain object can suddenly report a field nobody supplied. It also makes recursive merging of untrusted data dangerous: a payload key that steers the walk to `Object.prototype` lets attacker-controlled values become defaults for the whole program, the class of bug known as prototype pollution. The defences are structural — use `Map` or `Object.create(null)` for untrusted key/value data, test with `Object.hasOwn` rather than truthiness, and reject reserved keys such as `__proto__`, `constructor` and `prototype` at the parse boundary.

code

javascript · 16 lines
javascript
const before = {};                 // created first
Object.prototype.isAdmin = true;   // one write, global reach

console.log(before.isAdmin);                   // true
console.log(Object.hasOwn(before, 'isAdmin')); // false — not its own
console.log('isAdmin' in {});                  // true
console.log(Object.keys(before));              // [] — own keys only

// containers that cannot be reached this way
const dict = Object.create(null);
console.log(dict.isAdmin);                     // undefined

const map = new Map();
console.log(map.get('isAdmin'));               // undefined

delete Object.prototype.isAdmin;               // clean up

go deeper

for a junior

Know that plain objects share one final prototype object, so anything added there appears on effectively every object, and that extending built-in prototypes is off-limits.

for a middle

Explain that lookups resolve through a live link at read time rather than being copied at creation, and show which checks are fooled — truthiness, in, for...in — versus which are not.

for a senior

Describe the untrusted-merge attack path concretely and choose defences by strength: structural containers and boundary validation first, own-property checks throughout, prototype freezing only as defence in depth.

for a principal

Own it as a systemic rule: externally supplied names must never reach a write path over shared objects. Decide where that boundary lives, whether it is enforced by schema validation, and what the process-wide hardening posture costs in dependency compatibility.

## Why the reach is global A plain object literal, an object from `new Object()`, an array, a function — all end their prototype chain at `Object.prototype`. Property reads resolve by walking that chain at access time. Nothing is copied into an object when it is created; it holds a live link. So this is not "newly created objects inherit the change" — it is *every* object without its own `isAdmin`, whenever it is next read: ```js const before = {}; Object.prototype.isAdmin = true; before.isAdmin; // true Object.hasOwn(before, 'isAdmin'); // false ``` The object is unchanged; the shared object it delegates to is not. ## What this breaks 1. **Presence checks.** `if (opts.isAdmin)` and `if ('isAdmin' in opts)` both now succeed on an empty options bag, because the truthiness read and `in` both consult the chain. 2. **Iteration.** `for...in` visits inherited *enumerable* string-keyed properties, so a plain assignment to `Object.prototype` (which produces an enumerable property) appears in every such loop in the program. `Object.keys` and `Object.entries` are own-property based and are unaffected. 3. **Guards that look sound.** Code written as "treat missing config as safe defaults" now reads a supplied value it never validated. This is exactly why extending built-in prototypes is a long-standing prohibition: your addition is visible to every library in the process, and two libraries adding the same name silently fight. ## The untrusted-merge version The hostile case is a deep-merge or set-by-path helper applied to parsed input: ```js function merge(target, source) { for (const key of Object.keys(source)) { if (typeof source[key] === 'object' && source[key] !== null) { merge(target[key] ??= {}, source[key]); // reads target[key] first } else { target[key] = source[key]; } } } ``` `JSON.parse` itself is safe — it creates ordinary own properties, even for a key spelled `__proto__`. The danger is the recursion: reading `target[key]` for the reserved key resolves through the chain to the shared prototype object, and the next level of the merge then writes attacker-chosen names onto that shared object. From then on, every plain object in the process appears to carry those values, which is how a merge of a request body turns into an authorization or template-rendering bug elsewhere. Keys named `constructor` and `prototype` give a similar route. The general shape is worth stating plainly: **a write path that accepts externally supplied property names can reach shared state, because names are how the chain is navigated.** ## Defences, strongest first - **Do not use plain objects as dictionaries for untrusted keys.** `new Map()` has no prototype-chain semantics for its entries at all — `map.get('__proto__')` is just a miss. This removes the whole class of problem rather than patching it. - **Use `Object.create(null)`** when you do want object syntax. The result has no prototype, so there is nothing above it to reach and no inherited names to confuse a check. (Its downside is that it has no `toString`, `hasOwnProperty` and so on — which is precisely why own-property helpers must be called as `Object.hasOwn(obj, key)`.) - **Reject reserved keys at the boundary.** Skip `__proto__`, `constructor` and `prototype` when copying externally supplied keys, ideally in one shared parse/validate step rather than at every call site. Schema validation that strips unknown keys achieves the same thing more thoroughly. - **Test membership with `Object.hasOwn`,** not truthiness or `in`, whenever the object is data. That makes an inherited value invisible to your logic even if something else polluted the prototype. - **`Object.freeze(Object.prototype)`** as a process-wide hardening step blocks the write outright, and in strict mode the attempt throws instead of failing silently. It is blunt — it breaks any library that legitimately extends built-ins, and it must run before untrusted code — so treat it as defence in depth, not the primary fix. ## The takeaway The prototype chain gives you cheap sharing of behaviour, and sharing has no direction: what you can read, anything that pollutes the shared end can write. Keep data containers off the shared chain, and keep property names that came from outside the program away from write paths.

  • Is `JSON.parse` on hostile input dangerous by itself?
    No. `JSON.parse` defines ordinary own properties on a fresh object, including for a key spelled `__proto__`, so the parsed object is inert. The risk appears in what you do next: a recursive merge, a set-by-path helper, or any copy loop that uses externally supplied names as property names on a write path.
  • Why does an inherited property show up in `for...in` but not in `Object.keys`?
    `for...in` visits enumerable string-keyed properties along the whole prototype chain, while `Object.keys` returns only the object's own enumerable string keys. A plain assignment to a prototype creates an enumerable property, so it appears in every `for...in` loop over affected objects and in none of the own-key APIs.
  • What do you lose by using `Object.create(null)` for a dictionary?
    Everything from `Object.prototype`: no `toString`, so string coercion throws; no `hasOwnProperty`, `valueOf` or `isPrototypeOf` methods to call directly. Use `Object.hasOwn(obj, key)` and explicit formatting instead. Many teams reach for `Map` instead, which also keeps insertion order and allows non-string keys.
  • Is freezing `Object.prototype` a complete fix?
    No, it is hardening. It blocks writes to that one object — and makes them throw in strict mode — but does nothing for pollution of other shared prototypes, and it breaks any dependency that legitimately extends built-ins. The primary fixes stay structural: untrusted key/value data in `Map` or null-prototype objects, and reserved names rejected at the boundary.

saying these in an interview costs you the question

  • Says only objects created after the write are affected
  • Claims JSON.parse alone pollutes the prototype
  • Thinks Object.keys would reveal the inherited property
  • Believes each object holds a copy of its prototype
  • Says a truthiness check on the field is a sufficient guard

context