jsonpost
JavaScriptSecurityParsing

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.

JSONPost··6 min read
Parse Untrusted JSON Safely

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