JSON Security: Injection, Prototype Pollution and Safe Parsing

Advanced
13 min

JSON Security: Injection, Prototype Pollution and Safe Parsing

JSON has no security features of its own; the code around the parser decides whether a payload is harmless or a way in. In this lesson you will see three classes of attack on JSON handling: injection through string building, prototype pollution through __proto__ keys, and resource exhaustion or logic bypass through hostile documents, plus the habit that closes each hole.

JSON Injection: Never Build JSON From Strings

The oldest mistake is assembling JSON with string concatenation or template literals. Any quote in the input becomes structure:

javascript
const name = 'x","admin":true,"note":"'; const body = '{"admin":false,"user":"' + name + '"}'; // {"admin":false,"user":"x","admin":true,"note":""} -> JSON.parse(body).admin === true const safe = JSON.stringify({ admin: false, user: name }); // {"admin":false,"user":"x\",\"admin\":true,\"note\":\""} -> admin stays false

JSON.stringify escapes every quote, backslash and control character, so input can only ever be a string value. The rule holds in every language: use the serializer, never string templates, and never eval() to parse JSON.

The reverse direction needs care too. Embedding JSON in an HTML page, for example server-rendered initial state inside a <script> tag, allows a string containing </script> to close the tag and inject markup. Escape < before embedding:

javascript
const inline = JSON.stringify(state).replace(/</g, "\\u003c"); // "<" is still valid JSON and still parses to "<", but the browser never sees a tag

Frameworks such as Next.js do this automatically; do it yourself when writing JSON into HTML by hand.

Prototype Pollution

In JavaScript, JSON.parse('{"__proto__": {...}}') creates an ordinary own property named __proto__; that alone is safe. The damage happens when application code copies the parsed object into another with a recursive merge, deep-extend or set-path helper. Reading target["__proto__"] returns Object.prototype, and the merge then writes into it, as the example at the top of this lesson shows. From then on every object inherits isAdmin: true, and any if (user.isAdmin) check passes.

Real incidents have hit lodash, jQuery and many configuration loaders. The defenses:

  • Block the keys. Skip __proto__, constructor and prototype in any merge or path-setting function, and iterate with Object.keys or Object.hasOwn instead of for...in.
  • Use prototype-less containers for user-keyed data: Object.create(null) or a Map have no __proto__ to reach.
  • Validate the shape with a JSON Schema that sets additionalProperties: false, so unexpected keys are rejected before they reach business logic.
  • Harden the runtime. Object.freeze(Object.prototype) at start-up and Node's --disable-proto=delete flag turn a successful pollution into a no-op or an error.

Parsing Hostile Documents

Even without injection, a document can be built to exhaust resources or exploit parser differences:

| Threat | Effect | Defense | |--------|--------|---------| | Huge body | Memory and CPU spent before validation | express.json({ limit: "100kb" }) or equivalent size cap | | Deep nesting | Stack overflow in recursive parsers | Cap depth or reject after parsing | | Duplicate keys | JavaScript and Python keep the last value, other parsers may keep the first, so two services see different data | Reject duplicates with a strict parser or validate after parsing | | Oversized integers | 9007199254740993 rounds silently, so two IDs can collide | Transmit IDs as strings | | Wrong content type | Parser runs on non-JSON, or a body parser is bypassed | Check Content-Type: application/json and use strict: true in express.json() |

The duplicate-key row matters most: real authorization bypasses exist where a gateway validates the first "role" and the backend applies the second. When two systems parse the same document, canonicalize it once at the boundary.

A Safe Parsing Routine

Put the checks together into one routine at the boundary of your service:

javascript
import Ajv from "ajv"; const ajv = new Ajv({ allErrors: true }); const validate = ajv.compile({ type: "object", additionalProperties: false, required: ["user"], properties: { user: { type: "string", maxLength: 64 }, admin: { type: "boolean" } } }); export function parseRequest(text) { if (text.length > 100_000) throw new Error("payload too large"); const data = JSON.parse(text); if (!validate(data)) throw new Error(ajv.errorsText(validate.errors)); return data; }

Size check, parse, schema validation with unknown keys rejected, and only then use the data. Everything downstream can trust the shape of data.

Quick Quiz
Question 1 of 3

Why is `'{"user":"' + name + '"}'` dangerous?

Key Takeaways

  • Always produce JSON with a serializer and parse it with a parser; string templates and eval are the root of injection.
  • Escape < as < when embedding JSON inside HTML.
  • Prototype pollution comes from merges that follow __proto__; block those keys, use Object.create(null) or Map, and freeze Object.prototype if you can.
  • Cap body size and depth, treat duplicate keys and huge integers as hostile, and verify the content type.
  • Validate every untrusted document against a schema with additionalProperties: false before using it.

Next lesson: JSON Best Practices and Tools — naming, structure and tooling conventions that keep JSON consistent across a whole codebase.

JSON Security: Injection, Prototype Pollution and Safe Parsing - JSON | CodeYourCraft | CodeYourCraft