Posted in Blog · Reading time ~12 min

JSON security pitfalls every API developer should know

JSON parsing looks innocent — call JSON.parse, get an object. The risks are mostly in what you do with the result, but a few are in the parse itself. This post walks through the seven JSON-related security issues that show up in real production APIs: prototype pollution, JSON-text injection, deeply-nested DoS, large-number DoS, hash-collision DoS, JSONP leftovers, and schema-strictness gaps. Each section has a working repro and the smallest fix.

The JSON Validator on this site enforces parse depth and rejects values that would round to a different number, both of which are defenses against the DoS pitfalls below. Useful to test "is this payload weaponized" without running it through your real parser.

1. Prototype pollution

The most famous JSON-adjacent vulnerability in the JavaScript ecosystem. When user-controlled JSON includes keys like __proto__, constructor, or prototype, and the consumer merges the parsed object into something else by walking keys recursively, the attacker can mutate Object.prototype and affect every object in the process.

The toy version:

function unsafeMerge(target, source) {
  for (const k of Object.keys(source)) {
    if (typeof source[k] === 'object' && source[k] !== null) {
      target[k] = target[k] || {};
      unsafeMerge(target[k], source[k]);
    } else {
      target[k] = source[k];
    }
  }
}

const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
unsafeMerge({}, payload);
({}).isAdmin;          // → true

Every plain object in the process now has isAdmin: true. If a downstream check is if (user.isAdmin) ... on an object that doesn't have that key set, the check passes.

Fixes:

  • Don't write your own deep-merge. Use structuredClone, Object.assign (shallow, safer), or a library like lodash.merge with a known-fixed version.
  • If you must walk keys, reject __proto__, constructor, prototype explicitly:
    if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
  • Use Object.create(null) for objects you'll merge into. They have no prototype, so polluting __proto__ on them is harmless.

JSON.parse itself sets keys with the safe [[DefineOwnProperty]], not with [[Set]], so the parse step alone is fine — the issue is the merge.

2. JSON-text injection

This is the JSON cousin of SQL injection. It happens when a service constructs JSON by string concatenation:

// DON'T
const body = `{"name": "${userName}", "role": "user"}`;

If userName is Alice", "role": "admin, the body becomes:

{"name": "Alice", "role": "admin", "role": "user"}

Two role keys. JSON technically permits this (the spec says behavior is undefined for duplicate keys) and most parsers take the last value, which would be "user". But some don't, and even when they do, the upstream attacker now has a primitive for "inject any field". Combined with a service that uses the parsed object as authorization input, you have privilege escalation.

Fix: never build JSON by concatenation. Use JSON.stringify:

const body = JSON.stringify({ name: userName, role: 'user' });

If you absolutely must template a JSON string by hand (template engines, code generators), at least JSON-escape the interpolated values: ${JSON.stringify(userName)} without the outer quotes.

3. Deeply-nested DoS

JSON has no inherent depth limit. The spec allows arbitrary nesting. Most parsers parse recursively, which means a payload like

[[[[[[[[[[ ... ]]]]]]]]]]

nested 100,000 levels deep can blow the call stack and crash the process. Cheap to produce on the attacker side (a 200 KB request body), expensive on the server side (a worker dies; if there's a sidecar that restarts it, you spend cycles; if there isn't, the process is gone).

Demonstration:

const evil = '['.repeat(100000) + ']'.repeat(100000);
JSON.parse(evil);
// → RangeError: Maximum call stack size exceeded

Modern JSON.parse implementations have somewhat raised stack limits, but the attack is still cheap at 1-2 MB of input. Fixes:

  • Limit request body size. Always. Even on internal APIs. The line of code that sets express.json({ limit: '100kb' }) is the cheapest defense in the post.
  • Reject parses that exceed a depth limit. Ajv and most schema validators can enforce a maximum depth in their formats option. Standalone: secure-json-parse (Node), Python's json module accepts a custom decoder.

4. Large-number / large-string DoS

Two variants:

Large numbers. A JSON number like 1e1000000000 is a few bytes on the wire but represents an absurd value. Strict parsers reject it (it overflows to Infinity); lenient ones don't. If your consumer multiplies, exponentiates, or otherwise computes with the result you can cook a CPU.

Large strings. A JSON string can be megabytes long. The parser has to allocate that string. If your schema doesn't have maxLength, and you're running 10 worker processes, an attacker sending 10 simultaneous 500 MB strings to your email field has just exhausted 5 GB of RAM.

Fixes:

  • Body size limit (again).
  • maxLength on every string field in your schema.
  • Numeric bounds (minimum, maximum) on every number field. If the field is "age in years", say 0 to 150.
  • If you use BigInt-aware parsers, set a digit limit on the parser itself — Python's int coercion got CVE-2020-10735 for exactly this; the fix was sys.set_int_max_str_digits().

5. Hash-collision DoS

Object lookup is O(1) on average, O(n) worst-case if every key hashes to the same bucket. If an attacker can pick keys for a JSON object and the language's hash function is predictable (Java pre-7, PHP pre-5.3.9, Python in some configurations), they can construct a payload of N keys that all collide, turning the parse into O(N²) — a few thousand keys is enough to stall a process.

Modern JavaScript engines, Java 8+, Python 3.4+, Go, Ruby 2.0+ all use randomized hash seeds and are not vulnerable. But if you're parsing JSON in an old language runtime, check.

Fix: don't run a vulnerable runtime. If you can't avoid it, cap the number of keys allowed in an object via schema (maxProperties).

6. JSONP leftovers

JSONP was the pre-CORS way to do cross-origin requests: the server returned callback({"user": "alice"}) as JavaScript, the client included it via <script>. It still appears in legacy endpoints. The risk is that anyone who can include a <script> tag pointing at the endpoint gets the response back, bypassing same-origin policy. If the response includes user-specific data and the client is authenticated by cookie, it's a one-stop-shop for data exfiltration.

Fix: turn JSONP endpoints off. If you genuinely need cross-origin reads, configure CORS properly with Access-Control-Allow-Origin set explicitly (not * when credentials are involved).

7. Schema-strictness gaps

This isn't a parse issue, it's a downstream issue, but it shows up so often that it belongs on the list. Your JSON Schema validates the payload, the payload passes, the handler trusts it, and an unexpected field (typo, extra key, attacker-injected field) gets used unsafely.

Concrete example: a PATCH endpoint that accepts a partial user record and copies every key into the user document. The schema lists name, email, bio as properties but forgets additionalProperties: false. Attacker sends {"role": "admin"}. Validator says "fine, none of the listed fields are present, and the schema doesn't say anything about role". Handler copies role: "admin" into the document. Game over.

Fixes:

  • additionalProperties: false on every object schema. The schema quickstart has the long version.
  • Whitelist what you copy server-side, even when the schema validates. Two lines of code is cheaper than the postmortem.
  • Don't share the same schema between "what we accept" and "what we publish" — they have different threat models.

The boring stuff that solves most of it

If you do five things, you avoid 90% of JSON-related security trouble:

  1. Set a body-size limit on every endpoint.
  2. Set a parse-depth limit at the parser.
  3. Validate every incoming JSON against a schema with additionalProperties: false and per-field length/numeric bounds.
  4. Never build JSON by string concatenation.
  5. Never deep-merge user-controlled objects without filtering __proto__, constructor, prototype.

The point of going through all of this is so the boring stuff actually gets done. The exotic-sounding CVEs are usually one missing line of config away from being impossible.

To exercise the defences above against a real payload: paste it into the JSON formatter and tree viewer to inspect the shape, run the JSON validator to ensure it parses cleanly, and then check it against your contract with the JSON Schema validator using additionalProperties: false.

If you've found another JSON-specific class of bug that should be on this list, send us details — we update this post.