Skip to content
jsonforge.app
Back to blog
Guide10 min read

JSON Security: Common Vulnerabilities and How to Avoid Them

JSON itself has no executable code, unlike XML with its external entity risks or YAML with its type coercion surprises — so it has a reputation for being inherently safe to parse. That reputation is mostly deserved for the parsing step itself, but what your code does with the parsed object afterward is where real vulnerabilities live.

Prototype pollution via JSON input

JavaScript objects inherit from a shared prototype chain. If untrusted JSON contains a key like "__proto__" and your code merges it into an existing object without guarding against special keys, an attacker can inject properties onto Object.prototype itself — affecting every object in your application, not just the one being merged into.

A malicious payload targeting a naive deep-merge function.
json
{
  "__proto__": {
    "isAdmin": true
  }
}

If this gets deep-merged into a user object with a recursive merge that doesn't special-case __proto__, constructor, or prototype keys, every object in the process — including ones that never touched this specific payload — can suddenly have an isAdmin property with value true. This has been a real vulnerability class in several popular npm merge/extend libraries.

How to avoid prototype pollution

Use Object.create(null) for maps built from untrusted keys, since it has no prototype for pollution to reach. Prefer Map over plain objects when the keys come from user input and you don't need JSON serialization on the result. If you must deep-merge untrusted JSON into an existing object, use a merge utility that explicitly blocks __proto__, constructor, and prototype as keys — check whether your dependency has patched this (most major libraries have, as of recent versions) rather than assuming it's handled. Keep dependencies current: this exact vulnerability class has been patched multiple times across popular packages as new bypass techniques were discovered.

Resource exhaustion: size and depth limits

Parsing untrusted JSON costs CPU and memory in proportion to the input, so the first defense is a size limit at the door. In Express, `app.use(express.json({ limit: "1mb" }))` rejects oversized bodies before parsing; every framework has an equivalent. Without a limit, a client that uploads a 2 GB body gets to allocate 2 GB per request — and the parsed object is usually several times larger than its text.

Depth is the second axis. V8's JSON parser is iterative and survives nesting far beyond what any legitimate payload uses, but the code that walks the result usually isn't: a recursive validator, serializer, or template renderer over a document nested tens of thousands of levels deep will overflow the stack (RangeError: Maximum call stack size exceeded), and JSON.stringify itself is recursive and throws on sufficiently deep structures. Schema validators can cap depth (Ajv and Zod both support limits), and a depth check before any recursive traversal is cheap insurance. The attack payload is small: `[` repeated 100,000 times is about 100 KB of request body.

Insecure deserialization beyond plain JSON.parse

Plain JSON.parse() only ever produces plain data — strings, numbers, booleans, null, plain objects, and arrays. It cannot construct arbitrary class instances or execute code, unlike some other serialization formats (Python's pickle, for example, can execute arbitrary code during deserialization). The risk shows up when a reviver function or a downstream step reconstructs class instances or executes logic based on untrusted field values — for example, a "type" field in the JSON used to dynamically require() or dispatch to a handler by string name without validating that the string is on an allowlist.

Embedding JSON in HTML: the </script> problem

A server-rendered page that embeds JSON — hydration data, feature flags, preloaded state — inside a <script> tag has a subtle escape hatch: the HTML parser doesn't know JSON syntax. It scans for </script> and ends the tag there, even when that sequence appears in the middle of a JSON string value. A user whose display name is `</script><script>alert(1)</script>` just broke out of your data island into executable context.

The tag terminates inside the string value; the rest renders as markup.
html
<script id="__DATA__" type="application/json">
  { "displayName": "</script><script>alert(1)</script>" }
</script>

The fix is to escape the payload for the HTML script context: replace < with `\u003c`, > with `\u003e`, & with `\u0026`, and the U+2028/U+2029 line separators, which older JavaScript parsers treat as literal newlines. All of those escapes are valid inside JSON strings and invisible to JSON.parse, so the data round-trips unchanged while the HTML parser can no longer be tricked. Most frameworks' serialization helpers do this for you — the bug arises when someone hand-rolls JSON.stringify into a template literal.

Validate against a schema before trusting untrusted JSON

JSON.parse() only guarantees syntactic validity — it says nothing about whether the data matches the shape your code expects. A request body that's valid JSON but missing a required field, or with a string where a number was expected, can crash downstream code or silently produce wrong behavior. Validating incoming JSON against a schema (JSON Schema, Zod, Joi, or similar) before using it catches malformed or unexpected structure at the boundary, gives you a clear rejection point with a useful error message instead of a confusing crash three functions deep, and doubles as living documentation of what shape the endpoint actually expects.

Secrets, logs, and pasting payloads into tools

JSON payloads routinely carry secrets — auth tokens echoed into logs, personal data in user records, connection strings in config dumps. Three habits cover most of it. Redact before logging: serialize an allowlist of fields, never the raw request body. Treat any JSON you're about to commit — fixtures, samples, bug repros — as a secret-scanning target, the same way you'd scan for hardcoded API keys. And before pasting production payloads into a web tool, check where it processes data: browser-side tools parse locally and never transmit anything, while server-side ones ship your customers' data to whatever backs them. (JsonForge's tools run in your browser for this reason.)

A practical checklist

Validate untrusted JSON against a schema at the boundary, before it reaches business logic. Never deep-merge untrusted JSON into long-lived objects without a merge utility that blocks dangerous keys. Avoid reviver functions or dynamic dispatch based on untrusted string fields unless the values are checked against an allowlist. Keep JSON-handling dependencies (parsers, merge utilities, schema validators) up to date — this is an actively evolving vulnerability class. Set reasonable size limits on JSON request bodies to reduce exposure to resource-exhaustion attacks via deeply nested or extremely large payloads.

FAQ

Is JSON.parse() itself vulnerable to code execution, like eval()?
No. JSON.parse() only ever produces plain data structures (strings, numbers, booleans, null, plain objects, arrays) and never executes code, unlike eval()-based JSON parsing which some very old codebases used before native JSON.parse() was standard. Modern JSON.parse() is safe with respect to code execution; the risks discussed here live in what your code does with the parsed result afterward.
What is prototype pollution in simple terms?
It's a vulnerability where attacker-controlled input (often JSON with a __proto__ key) gets merged into an object using code that doesn't guard against special property names, letting the attacker add or overwrite properties on the shared base object that all JavaScript objects inherit from — affecting the whole application, not just one object.
Do I need schema validation if I already use TypeScript?
Yes — TypeScript types are erased at compile time and provide zero runtime protection. A malformed or malicious JSON payload arriving over HTTP is checked by nothing at runtime unless you explicitly validate it with a library like Zod, Joi, or a JSON Schema validator. TypeScript prevents you from writing code that mishandles the expected shape; it does nothing to verify the actual data matches that shape.
Are YAML or XML safer than JSON to parse?
Not inherently — they have their own vulnerability classes. YAML parsers in some languages support type coercion and tags that can construct arbitrary objects if not configured in a restricted "safe load" mode. XML parsers are vulnerable to XML External Entity (XXE) attacks if entity expansion isn't disabled. JSON's simpler type system (no tags, no entities) removes those specific attack surfaces, but the deserialization and merging risks covered here still apply.
Is it safe to paste production JSON into an online formatter or validator?
It depends entirely on where the tool processes your data. Browser-side tools parse locally and never transmit the payload; server-side ones send it to whatever backs them. Check for "client-side" or "in your browser" processing before pasting anything with tokens, personal data, or production payloads — once it reaches someone else's server, you've lost control of it.

Try these tools

Related articles