Security Risk Checklist for JSON Processing
Bottom line: JSON.parse itself is safe; the danger is what happens after it: deep merging, assignment by path, inlining into HTML, unlimited depth and size.

Bottom line: close the two entry points first — every deep merge and path assignment must reject proto, prototype, and constructor, and request-body size plus nesting depth need hard limits that apply before parsing. Watching JSON.parse itself leaves the dangerous doors wide open.
Attackers don't need to invent a new vulnerability; they just add one more key to the JSON.
1. Prototype Pollution: The Most Common and Most Overlooked
1.1 JSON.parse itself is safe
const data = JSON.parse('{"__proto__": {"polluted": true}}');
console.log(data.polluted); // undefined
console.log({}.polluted); // undefined
JSON.parse creates __proto__ as an ordinary own property and does not rewrite the prototype chain. So the claim that "JSON.parse causes prototype pollution" is inaccurate.
1.2 The merge that follows is what's dangerous
/**
* Unsafe deep merge: writes the source object's keys straight into the target object.
* @param target Target object
* @param source Source object (may come from user JSON)
* @returns Merged object
*/
function unsafeMerge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = target[key] ?? {};
unsafeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
unsafeMerge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
console.log({}.isAdmin); // true — the entire site is polluted
constructor.prototype is another entry point of the same kind:
{ "constructor": { "prototype": { "isAdmin": true } } }
1.3 How to fix it
/** Key names that must be rejected */
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
/**
* Safe deep merge: rejects prototype-related keys and handles own properties only.
* @param target Target object
* @param source Source object
* @returns New merged object
*/
function safeMerge(target, source) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (FORBIDDEN_KEYS.has(key)) {
continue;
}
const sourceValue = source[key];
const isPlainObject =
typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue);
if (isPlainObject) {
result[key] = safeMerge(target[key] ?? {}, sourceValue);
continue;
}
// Use defineProperty so a setter cannot trigger prototype mutation
Object.defineProperty(result, key, {
value: sourceValue,
enumerable: true,
writable: true,
configurable: true,
});
}
return result;
}
A more thorough approach is to use Object.create(null) as the container (there is no prototype chain to pollute), or simply a Map.
When using an off-the-shelf library, confirm that it already handles these three keys — many older versions of lodash merge were reported for exactly this.
1.4 Assignment by path is equally dangerous
// Anti-pattern: the path comes from user input
for (const segment of pathSegments) {
current = current[segment] ?? (current[segment] = {});
}
When __proto__ shows up in pathSegments, this is equivalent to the pollution above. Implementations of JSON Pointer / JSON Patch must validate every token segment.
2. Resource Exhaustion Attacks
| Attack | Tactic | Mitigation |
|---|---|---|
| Deep nesting | A hundred thousand levels of [[[[...]]]] |
Limit nesting depth (usually 32–64) and pre-scan before parsing |
| Oversized arrays | An array with tens of millions of elements | Limit request body size and array length |
| Extremely long strings | A single string of hundreds of MB | Limit field length |
| Duplicate-key explosion | A huge number of duplicate keys | Limit the number of object keys |
The two most effective defenses on the server are request body size limits and depth limits, and both must take effect before any parsing happens.
/**
* Cheaply checks nesting depth before parsing to prevent stack overflow during parsing.
* @param text JSON text
* @param maxDepth Maximum allowed depth
* @returns Whether the depth is within the allowed range
*/
function isDepthAcceptable(text: string, maxDepth = 64): boolean {
let currentDepth = 0;
let isInsideString = false;
let isEscaped = false;
for (const character of text) {
if (isInsideString) {
if (isEscaped) {
isEscaped = false;
} else if (character === '\\') {
isEscaped = true;
} else if (character === '"') {
isInsideString = false;
}
continue;
}
if (character === '"') {
isInsideString = true;
} else if (character === '{' || character === '[') {
currentDepth += 1;
if (currentDepth > maxDepth) {
return false;
}
} else if (character === '}' || character === ']') {
currentDepth -= 1;
}
}
return true;
}
3. Injection When Inlining into HTML
Writing JSON directly into a page is a common practice, but it introduces three classes of problems:
<script>
window.__DATA__ = {"title": "</script><script>alert(1)</script>"};
</script>
The </script> inside the string closes the script block early, and the rest is parsed as HTML — a classic XSS.
Two more characters behave the same way: \u2028 (line separator) and \u2029 (paragraph separator) are legal in JSON, but embedding them in JS source triggers a syntax error.
Fix:
/**
* Serializes JSON safely for an inline script.
* @param value Data object
* @returns String that can be safely placed inside a script tag
*/
function serializeForInlineScript(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
A more robust approach is to put the data inside <script type="application/json"> (whose content is not executed), then read it with JSON.parse(document.getElementById('data').textContent).
4. JSON Hijacking and JSONP
Early browsers allowed a cross-origin <script src="https://api.example.com/user"> to execute the returned JavaScript directly,
and JSON whose top level is an array happens to be a valid JS expression as well, so an attacker could read the data by overriding the Array constructor.
Modern browsers already have protections, but we still recommend:
- Use an object rather than an array at the top level of the API (
{"data": [...]}); - Send
X-Content-Type-Options: nosniffand setContent-Type: application/jsonstrictly; - Stop using JSONP; use CORS when cross-origin access is genuinely needed.
5. Type Confusion When Deserializing into Objects
Once default typing is enabled in Jackson (enableDefaultTyping), an attacker can specify a @class field to make the server instantiate an arbitrary class,
and combine it with an exploitable gadget chain to achieve remote code execution. Vulnerabilities of this kind have appeared many times (both Fastjson and Jackson have been hit).
Principle: the target type of deserialization must be statically specified by server code and must never be determined by the payload content.
6. Denial of Service in Regular Expressions and Schemas
- JSON Schema's
patternand JSONPath'smatch()both accept regular expressions; - Patterns like
(a+)+$backtrack exponentially on long inputs; - If the regex comes from users (custom validation rules), you must limit its length, disallow backreferences and nested quantifiers, or switch to a linear engine such as RE2.
7. Logging and Masking
JSON logs are the easiest way to leak sensitive information:
/** Field names that must be masked in logs (matched in lowercase) */
const SENSITIVE_KEYS = new Set(['password', 'token', 'secret', 'idcard', 'phone', 'authorization']);
/**
* Recursively masks sensitive fields in an object for log output.
* @param value Original value
* @returns Masked copy
*/
function maskSensitive(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => maskSensitive(item));
}
if (typeof value === 'object' && value !== null) {
return Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => {
if (SENSITIVE_KEYS.has(entryKey.toLowerCase())) {
return [entryKey, '***'];
}
return [entryKey, maskSensitive(entryValue)];
}),
);
}
return value;
}
Note that masking must happen before the log is written, not by cleaning up the logging platform afterwards.
8. Pre-launch Checklist
- [ ] All deep-merge functions reject
__proto__/prototype/constructor; - [ ] JSON Pointer / Patch path tokens are validated against a whitelist;
- [ ] Request body size and nesting depth have hard limits, and those limits apply before parsing;
- [ ] JSON inlined into HTML escapes
<,>,&,U+2028, andU+2029; - [ ] APIs use an object at the top level and responses send
nosniff; - [ ] Deserialization target types are statically specified in code;
- [ ] User-controlled regular expressions have complexity limits;
- [ ] Log fields have masking rules and those rules are tested.
FAQ
How do I check whether my deep merge is vulnerable to prototype pollution?
Feed JSON.parse('{"__proto__": {"isAdmin": true}}') into the merge function, then check whether {}.isAdmin turned true; repeat with a constructor.prototype payload. With a third-party library, confirm it rejects __proto__, prototype, and constructor — many older lodash merge versions were reported for exactly this. Path-based assignment needs its own check, because a __proto__ token in the path pollutes the same way.
Why is JSON.parse itself not the source of prototype pollution?
It creates __proto__ as an ordinary own property and never rewrites the prototype chain, so both data.polluted and {}.polluted stay undefined. The pollution happens afterwards: an unsafe deep merge writes the source object's keys straight into the target, and constructor.prototype is an entry point of the same kind. Treating JSON.parse as the root cause means fixing the wrong place.
Should JSON inlined into HTML always be escaped?
Yes. A </script> inside a string closes the script block early and the rest is parsed as HTML — a classic XSS; <, >, &, U+2028, and U+2029 need escaping too. A safer option is to put the data inside <script type="application/json">, whose content is not executed, and read it with JSON.parse on textContent.