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.
The oldest mistake is assembling JSON with string concatenation or template literals. Any quote in the input becomes structure:
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 falseJSON.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:
const inline = JSON.stringify(state).replace(/</g, "\\u003c");
// "<" is still valid JSON and still parses to "<", but the browser never sees a tagFrameworks such as Next.js do this automatically; do it yourself when writing JSON into HTML by hand.
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:
__proto__, constructor and prototype in any merge or path-setting function, and iterate with Object.keys or Object.hasOwn instead of for...in.Object.create(null) or a Map have no __proto__ to reach.additionalProperties: false, so unexpected keys are rejected before they reach business logic.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.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.
Put the checks together into one routine at the boundary of your service:
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.
Why is `'{"user":"' + name + '"}'` dangerous?
eval are the root of injection.< as < when embedding JSON inside HTML.__proto__; block those keys, use Object.create(null) or Map, and freeze Object.prototype if you can.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.