Security when parsing untrusted JSON
Prototype pollution, size and depth limits, schema validation as an actual control, and keeping JSON safe where it is embedded in a page.
Prototype pollution
// the attack: a key that walks into Object.prototype
const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === "object") {
target[key] = merge(target[key] ?? {}, source[key]); // recurses into __proto__
} else {
target[key] = source[key];
}
}
return target;
}
merge({}, payload);
({}).isAdmin; // true - every object in the process is now an admin// fixed: reject the special keys and use null-prototype objects
const BLOCKED = new Set(["__proto__", "constructor", "prototype"]);
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (BLOCKED.has(key)) continue;
const value = source[key];
if (value && typeof value === "object" && !Array.isArray(value)) {
const base = Object.hasOwn(target, key) ? target[key] : Object.create(null);
target[key] = safeMerge(base, value);
} else {
target[key] = value;
}
}
return target;
}
// or validate the keys with a schema before you merge anything
const parsed = Object.assign(Object.create(null), JSON.parse(body));- It is not
JSON.parsethat is dangerous - it is a recursive merge or a deep assignment that follows the keys it produced. Object.create(null)gives an object with no prototype to walk into, which removes the target of the attack.- Reject
__proto__,constructorandprototypeat the boundary, and additionally use a schema withadditionalProperties: false. - Use a maintained merge library that documents how it handles these keys, rather than writing the recursion yourself.
⚠️
A payload that is only parsed, logged and discarded is safe; the same payload merged into configuration is not. Audit for the assignment step, not just the parse: deep merge,
Object.assign onto a shared default, and any code that walks a key path from user input.Size, depth and cost
// bound the request body before it is parsed at all
app.use(express.json({ limit: "256kb" }));
// depth check, because a small payload can still be deeply nested
function depthOf(value, limit = 32) {
if (value === null || typeof value !== "object") return 0;
let max = 0;
for (const key of Object.keys(value)) {
const d = 1 + depthOf(value[key], limit);
if (d > limit) throw new Error("JSON nesting too deep");
if (d > max) max = d;
}
return max;
}| Control | Defends against |
|---|---|
| Body size limit | Memory exhaustion from one request |
| Nesting depth limit | Stack overflow in recursive handlers |
| Array length limit | Expensive downstream processing |
| Request timeout | Slow-loris style resource holding |
| Schema validation | Unexpected shapes reaching business logic |
| Rate limiting | Volume-based abuse |
# what does your endpoint do with a 10 MB nested body?
python - <<'PY' > /tmp/deep.json
depth = 20000
print('{"a":' * depth + '1' + '}' * depth)
PY
curl -s -o /dev/null -w '%{http_code} %{size_upload}\n' \
-X POST http://localhost:3000/api/events \
-H 'Content-Type: application/json' \
--data-binary @/tmp/deep.jsonJSON embedded in HTML and logs
// a payload written into a page must not be able to close the containing block
function safeForInlineJson(value) {
return JSON.stringify(value)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
// escaping the opening angle bracket is what stops a string value
// from terminating the surrounding HTML element- The characters to escape when JSON sits inside HTML are
<,>,&and the two Unicode line separators - the rest are safe inside a JSON string. - Prefer a data attribute or a separate response over inlining data into a page; then the escaping question does not arise.
- When you log JSON, redact known-sensitive keys before serialising. Logs are widely readable and long-lived.
- Set
X-Content-Type-Options: nosniffso a browser cannot be persuaded to interpret a JSON response as HTML. - Escape on output, not on input. Storing escaped data means every consumer inherits the escaping decision.
// redaction before anything is written to a log
const SECRET = /^(password|token|authorization|card_number|cvv)$/i;
function redact(value) {
if (Array.isArray(value)) return value.map(redact);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, SECRET.test(k) ? "[redacted]" : redact(v)])
);
}
return value;
}FAQ
Is JSON.parse itself unsafe?
No. It builds plain values and never executes anything. The risk is entirely in what you do next - a recursive merge that follows
__proto__, a key path taken from user input, or rendering a value as markup. Audit the code that consumes the parsed object.How large should the body limit be?
As small as your largest legitimate request, with a little headroom. Measure the real maximum first; a limit chosen by guesswork is either too generous to protect anything or too tight to accept a valid upload.
Related
Dates, numbers and precision Testing and mocking JSON APIs
Last refreshed 2026-09-18.