Safely Parsing Untrusted JSON in JavaScript
Parse untrusted JSON in JavaScript without crashing or opening security holes — try/catch, revivers, prototype pollution, schema validation, size and depth limits.
Any time JSON arrives from outside your process — a third-party API, a webhook, a file upload, a query string — you should treat it as hostile until proven otherwise. Malformed input can crash a request handler, malicious input can pollute object prototypes, and oversized input can exhaust memory. The good news is that defending against all of this takes only a small, reusable helper. This post walks through the layers of a robust JSON parser in JavaScript and TypeScript.
Always wrap JSON.parse in try/catch
JSON.parse throws a SyntaxError on invalid input. If you call it on a raw
request body without protection, one bad character takes down the whole handler.
The first rule is simple: never call JSON.parse bare on untrusted data.
function tryParse(text) {
try {
return { ok: true, value: JSON.parse(text) };
} catch (err) {
return { ok: false, error: err.message };
}
}
Returning a result object instead of throwing forces the caller to handle the failure path explicitly, which is exactly what you want at a trust boundary.
Never use eval
It bears repeating because the anti-pattern still circulates: do not use eval
or the Function constructor to parse JSON. JSON.parse is faster, and eval
will happily execute arbitrary code embedded in the string. There is no scenario
where eval on untrusted text is acceptable.
// NEVER do this
const data = eval("(" + untrustedText + ")");
Guard the prototype with proto
JSON keys are just strings, but JavaScript object assignment treats some keys
specially. An attacker can send a payload containing __proto__ and, if you
merge it carelessly, poison Object.prototype for your entire process. This is
called prototype pollution.
Consider a payload whose key is the string __proto__ mapping to an object with
an isAdmin flag. After a naive recursive merge, every object in your program
could suddenly report isAdmin as true. The safest defense is a reviver that
drops dangerous keys during parsing, before any object is fully built:
const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]);
function safeReviver(key, value) {
if (FORBIDDEN.has(key)) return undefined;
return value;
}
const data = JSON.parse(text, safeReviver);
Note that the reviver removes the dangerous property, but for full protection
you should also avoid deep-merging parsed data into existing objects, and create
target objects with Object.create(null) so they have no prototype to pollute.
Use the reviver for safe transforms
The reviver function passed as the second argument to JSON.parse runs once per
key-value pair and lets you transform values as they are parsed. Beyond
stripping forbidden keys, it is the right place to coerce known date strings
into Date objects:
const ISO = /^\d{4}-\d{2}-\d{2}T/;
function reviver(key, value) {
if (FORBIDDEN.has(key)) return undefined;
if (typeof value === "string" && ISO.test(value)) {
const d = new Date(value);
if (!Number.isNaN(d.getTime())) return d;
}
return value;
}
Keep revivers cheap and side-effect free — they run on every node of the tree.
Handle BigInt and precision loss
JavaScript numbers are IEEE-754 doubles, so any integer larger than
Number.MAX_SAFE_INTEGER (about 9 quadrillion) loses precision silently when
parsed. If a third party sends a 64-bit ID, JSON.parse will quietly mangle it:
JSON.parse('{"id":9007199254740993}').id;
// 9007199254740992 — wrong, off by one
There is no flag to make JSON.parse produce a BigInt, so the practical fix
is to keep large numbers as strings on the wire and parse them deliberately on
the consuming side. If you control the producer, send the value as "id": "9007199254740993". If you do not, a reviver can detect a numeric string in a
known field and convert it:
function bigIntReviver(key, value) {
if (key === "id" && typeof value === "string" && /^\d+$/.test(value)) {
return BigInt(value);
}
return value;
}
Remember that JSON.stringify cannot serialize a BigInt directly — you must
convert it back to a string before sending it onward.
Limit size and depth
A megabyte of deeply nested brackets can pin a CPU and balloon memory before any of your logic runs. Enforce limits before and after parsing. Most frameworks let you cap the raw body size:
import express from "express";
const app = express();
app.use(express.json({ limit: "100kb" }));
Size alone is not enough — a small string can still describe pathological nesting. Add a depth check that walks the parsed value:
function maxDepth(value, depth = 0) {
if (depth > 64) throw new Error("JSON nesting too deep");
if (value && typeof value === "object") {
let max = depth;
for (const child of Object.values(value)) {
max = Math.max(max, maxDepth(child, depth + 1));
}
return max;
}
return depth;
}
Reject anything that exceeds a sane limit for your domain. Legitimate payloads are almost never 64 levels deep.
Validate the shape after parsing
Parsing tells you the input is syntactically valid JSON. It tells you nothing about whether the data has the fields you expect. A successful parse can still hand you a string where you wanted an array, or omit a required field. Always validate the shape before trusting it.
For lightweight checks, a guard function works:
type User = { id: string; name: string; age: number };
function isUser(v: unknown): v is User {
if (typeof v !== "object" || v === null) return false;
const o = v as Record<string, unknown>;
return (
typeof o.id === "string" &&
typeof o.name === "string" &&
typeof o.age === "number"
);
}
For anything non-trivial, describe the contract once with JSON Schema and validate against it. You can generate a starting schema from a sample and check documents with precise, path-based errors using the JSON Validator. This separates "is it valid JSON" from "does it match my contract," and the second question is the one that actually protects your code.
Dealing with malformed third-party JSON
Sometimes a partner sends technically broken JSON — trailing commas, single quotes, or unquoted keys — and you cannot make them fix it on your schedule. Rather than hand-rolling a tolerant parser, paste the offending payload into the JSON Fixer to repair common mistakes and see what the corrected structure should look like. Once you understand the failure pattern, you can decide whether to normalize it server-side or reject it outright. When the JSON is valid but ugly, the JSON Validator will confirm it and pinpoint exactly where any remaining error sits.
A complete parsing helper
Putting the layers together yields a single function you can drop in at any trust boundary:
const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]);
function reviver(key: string, value: unknown) {
return FORBIDDEN.has(key) ? undefined : value;
}
export function parseUntrusted<T>(
text: string,
validate: (v: unknown) => v is T,
maxBytes = 100_000
): { ok: true; value: T } | { ok: false; error: string } {
if (text.length > maxBytes) return { ok: false, error: "payload too large" };
let parsed: unknown;
try {
parsed = JSON.parse(text, reviver);
} catch {
return { ok: false, error: "invalid JSON" };
}
if (!validate(parsed)) return { ok: false, error: "shape mismatch" };
return { ok: true, value: parsed };
}
This single helper enforces a size cap, blocks prototype pollution, never throws, and refuses anything that does not match your expected shape.
Wrapping up
Safe parsing is layered: cap the size, catch the syntax errors, strip dangerous
keys, watch for precision loss, and validate the shape before you trust a single
field. None of these steps is expensive, and together they turn a fragile
JSON.parse call into a hardened boundary.
Try it now — validate a real payload with the JSON Validator and repair broken input with the JSON Fixer, both running locally in your browser. Browse the full toolkit at /tools/ and find more guides on the blog.
Keep reading
Validating JSON with JSON Schema
Learn how JSON Schema works, how to generate a schema from sample data, and how to validate documents with clear, path-based error messages.
JSON vs JSON5 vs JSONC — What's the Difference?
Comments, trailing commas, and unquoted keys — understand JSON5 and JSONC, where each is used, and how to convert them to strict JSON.
How to Format JSON (and Why It Matters)
A practical guide to beautifying, indenting, and minifying JSON — and when to use each, with tips for debugging messy API responses.