Security when parsing untrusted JSON

Prototype pollution, size and depth limits, schema validation as an actual control, and keeping JSON safe where it is embedded in a page.

Prototype pollution

// the attack: a key that walks into Object.prototype
const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');

function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === "object") {
      target[key] = merge(target[key] ?? {}, source[key]);   // recurses into __proto__
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

merge({}, payload);
({}).isAdmin;         // true - every object in the process is now an admin
// fixed: reject the special keys and use null-prototype objects
const BLOCKED = new Set(["__proto__", "constructor", "prototype"]);

function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (BLOCKED.has(key)) continue;

    const value = source[key];
    if (value && typeof value === "object" && !Array.isArray(value)) {
      const base = Object.hasOwn(target, key) ? target[key] : Object.create(null);
      target[key] = safeMerge(base, value);
    } else {
      target[key] = value;
    }
  }
  return target;
}

// or validate the keys with a schema before you merge anything
const parsed = Object.assign(Object.create(null), JSON.parse(body));
  • It is not JSON.parse that is dangerous - it is a recursive merge or a deep assignment that follows the keys it produced.
  • Object.create(null) gives an object with no prototype to walk into, which removes the target of the attack.
  • Reject __proto__, constructor and prototype at the boundary, and additionally use a schema with additionalProperties: false.
  • Use a maintained merge library that documents how it handles these keys, rather than writing the recursion yourself.
⚠️
A payload that is only parsed, logged and discarded is safe; the same payload merged into configuration is not. Audit for the assignment step, not just the parse: deep merge, Object.assign onto a shared default, and any code that walks a key path from user input.

Size, depth and cost

// bound the request body before it is parsed at all
app.use(express.json({ limit: "256kb" }));

// depth check, because a small payload can still be deeply nested
function depthOf(value, limit = 32) {
  if (value === null || typeof value !== "object") return 0;

  let max = 0;
  for (const key of Object.keys(value)) {
    const d = 1 + depthOf(value[key], limit);
    if (d > limit) throw new Error("JSON nesting too deep");
    if (d > max) max = d;
  }
  return max;
}
ControlDefends against
Body size limitMemory exhaustion from one request
Nesting depth limitStack overflow in recursive handlers
Array length limitExpensive downstream processing
Request timeoutSlow-loris style resource holding
Schema validationUnexpected shapes reaching business logic
Rate limitingVolume-based abuse
# what does your endpoint do with a 10 MB nested body?
python - <<'PY' > /tmp/deep.json
depth = 20000
print('{"a":' * depth + '1' + '}' * depth)
PY

curl -s -o /dev/null -w '%{http_code} %{size_upload}\n' \
  -X POST http://localhost:3000/api/events \
  -H 'Content-Type: application/json' \
  --data-binary @/tmp/deep.json

JSON embedded in HTML and logs

// a payload written into a page must not be able to close the containing block
function safeForInlineJson(value) {
  return JSON.stringify(value)
    .replace(/</g, "\\u003c")
    .replace(/>/g, "\\u003e")
    .replace(/&/g, "\\u0026")
    .replace(/\u2028/g, "\\u2028")
    .replace(/\u2029/g, "\\u2029");
}

// escaping the opening angle bracket is what stops a string value
// from terminating the surrounding HTML element
  • The characters to escape when JSON sits inside HTML are <, >, & and the two Unicode line separators - the rest are safe inside a JSON string.
  • Prefer a data attribute or a separate response over inlining data into a page; then the escaping question does not arise.
  • When you log JSON, redact known-sensitive keys before serialising. Logs are widely readable and long-lived.
  • Set X-Content-Type-Options: nosniff so a browser cannot be persuaded to interpret a JSON response as HTML.
  • Escape on output, not on input. Storing escaped data means every consumer inherits the escaping decision.
// redaction before anything is written to a log
const SECRET = /^(password|token|authorization|card_number|cvv)$/i;

function redact(value) {
  if (Array.isArray(value)) return value.map(redact);
  if (value && typeof value === "object") {
    return Object.fromEntries(
      Object.entries(value).map(([k, v]) => [k, SECRET.test(k) ? "[redacted]" : redact(v)])
    );
  }
  return value;
}

FAQ

Is JSON.parse itself unsafe?
No. It builds plain values and never executes anything. The risk is entirely in what you do next - a recursive merge that follows __proto__, a key path taken from user input, or rendering a value as markup. Audit the code that consumes the parsed object.
How large should the body limit be?
As small as your largest legitimate request, with a little headroom. Measure the real maximum first; a limit chosen by guesswork is either too generous to protect anything or too tight to accept a valid upload.

Dates, numbers and precision Testing and mocking JSON APIs

Last refreshed 2026-09-18.