JSON Security: Injection & Prototype Pollution Prevention
TL;DR
- JSON injection happens when untrusted input is concatenated into JSON strings instead of using
JSON.stringify()- Prototype pollution lets attackers modify
Object.prototypethrough malicious__proto__keys in parsed JSON- Deeply nested JSON (10,000+ levels) crashes parsers and can be used for denial-of-service attacks
- Always validate, sanitise, and limit the depth and size of incoming JSON before processing it
Table of Contents
- JSON Injection
- Prototype Pollution
- Denial of Service via Deep Nesting
- Mass Assignment Vulnerabilities
- Server-Side Request Forgery via JSON
- Secure JSON Parsing Patterns
- Security Checklist
- Frequently Asked Questions
JSON Injection
JSON injection occurs when user-supplied data is concatenated into a JSON string without proper escaping.
Vulnerable code:
// DANGEROUS: string concatenation
const json = '{"username": "' + userInput + '"}';
If userInput is admin", "role": "superadmin, the resulting JSON becomes:
{"username": "admin", "role": "superadmin"}
The attacker injected an additional field. If the server processes this JSON and trusts the role field, the attacker gains elevated privileges.
The fix is trivial: always use JSON.stringify():
// SAFE: JSON.stringify handles escaping
const data = { username: userInput };
const json = JSON.stringify(data);
JSON.stringify() automatically escapes special characters, making injection impossible. Never build JSON strings manually.
Database-level injection: NoSQL databases like MongoDB accept JSON-like query objects. If user input flows into query construction:
// DANGEROUS: MongoDB query injection
const query = JSON.parse(`{"username": "${userInput}"}`);
db.users.findOne(query);
If userInput is {"$gt": ""}, the query becomes {"username": {"$gt": ""}}, which matches all users. Always use parameterised queries or whitelist-validate input.
Prototype Pollution
Prototype pollution is one of the most dangerous JSON-specific attacks. It exploits how JavaScript handles object property lookups through the prototype chain.
The attack:
const maliciousJson = '{"__proto__": {"isAdmin": true}}';
const parsed = JSON.parse(maliciousJson);
// Now EVERY object in the application has isAdmin = true
const user = {};
console.log(user.isAdmin); // true (polluted!)
This works because JSON.parse creates an object with a __proto__ key. When JavaScript assigns __proto__, it modifies the prototype of Object, affecting every object created afterward.
Real-world impact: If any authorisation check does if (user.isAdmin), the attacker bypasses it for every user in the system.
Prevention:
// Method 1: Strip dangerous keys during parsing
function safeParse(json) {
return JSON.parse(json, (key, value) => {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
return undefined; // Remove the key
}
return value;
});
}
// Method 2: Create objects without prototype
function safeParseNoProto(json) {
const parsed = JSON.parse(json);
return Object.assign(Object.create(null), parsed);
}
// Method 3: Use Object.freeze on prototypes
Object.freeze(Object.prototype);
// WARNING: This breaks many libraries - use with caution
Method 1 (reviver function) is the recommended approach because it is compatible with all libraries and handles nested __proto__ keys.
Denial of Service via Deep Nesting
JSON parsers use recursion to handle nested objects and arrays. An attacker can send JSON with thousands of nesting levels to crash the parser:
# Generate malicious JSON with 100,000 nesting levels
malicious = '{"a":' * 100000 + '1' + '}' * 100000
When JSON.parse() processes this, it recursively descends 100,000 levels, exceeding the maximum call stack size and crashing the Node.js process.
Prevention:
function parseWithDepthLimit(json, maxDepth = 100) {
let depth = 0;
let maxReached = 0;
for (const char of json) {
if (char === '{' || char === '[') {
depth++;
maxReached = Math.max(maxReached, depth);
if (depth > maxDepth) {
throw new Error(`JSON exceeds maximum nesting depth of ${maxDepth}`);
}
} else if (char === '}' || char === ']') {
depth--;
}
}
return JSON.parse(json);
}
Also set maximum request body size on your web server:
// Express.js
app.use(express.json({ limit: '1mb' })); // Reject bodies over 1MB
Mass Assignment Vulnerabilities
Mass assignment happens when an API accepts a full JSON object and applies all fields to a database record without filtering:
// DANGEROUS: updates all fields from user input
app.put('/api/users/:id', async (req, res) => {
await db.users.update(req.params.id, req.body);
});
An attacker sends:
{
"name": "Normal Name",
"role": "admin",
"isVerified": true,
"billingPlan": "enterprise"
}
The server happily updates all four fields, giving the attacker admin privileges and a free enterprise plan.
Prevention: always whitelist allowed fields:
app.put('/api/users/:id', async (req, res) => {
const allowed = ['name', 'email', 'avatar'];
const updates = {};
for (const key of allowed) {
if (req.body[key] !== undefined) {
updates[key] = req.body[key];
}
}
await db.users.update(req.params.id, updates);
});
Or use Zod schemas (recommended) to strip unknown fields automatically:
const UpdateUserSchema = z.object({
name: z.string().optional(),
email: z.string().email().optional(),
avatar: z.string().url().optional(),
}).strict(); // Rejects unknown fields
const validated = UpdateUserSchema.parse(req.body);
Server-Side Request Forgery via JSON
If your API processes URLs from JSON input (webhook URLs, image URLs, redirect targets), an attacker can point them to internal services:
{
"webhookUrl": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
}
This URL targets the AWS metadata service, exposing IAM credentials. Always validate URLs against an allowlist of domains and block private IP ranges.
Secure JSON Parsing Patterns
The complete secure parsing function:
interface ParseOptions {
maxSize?: number; // Maximum JSON string length in bytes
maxDepth?: number; // Maximum nesting depth
stripProto?: boolean; // Remove __proto__ keys
}
function secureParse(json: string, options: ParseOptions = {}): unknown {
const { maxSize = 1_000_000, maxDepth = 50, stripProto = true } = options;
// Size check
if (json.length > maxSize) {
throw new Error(`JSON exceeds maximum size of ${maxSize} bytes`);
}
// Depth check
let depth = 0;
for (const char of json) {
if (char === '{' || char === '[') {
depth++;
if (depth > maxDepth) {
throw new Error(`JSON exceeds maximum depth of ${maxDepth}`);
}
} else if (char === '}' || char === ']') {
depth--;
}
}
// Parse with optional prototype stripping
if (stripProto) {
return JSON.parse(json, (key, value) => {
if (key === '__proto__' || key === 'constructor') return undefined;
return value;
});
}
return JSON.parse(json);
}
Use our JSON Formatter to inspect suspicious JSON payloads. The tree view makes it easy to spot unexpected keys like __proto__ or constructor. For testing your API endpoints against these attack patterns, use our API Request Tester.
For the complete JSON developer reference, see our pillar guide: The Complete JSON Guide for Web Developers.
For safe API response handling patterns, see: How to Handle JSON API Responses Safely.
The Debuggers provides security-focused development services, including API security audits and secure coding practices.
Security Checklist
| Check | How |
|---|---|
| Never build JSON with string concatenation | Use JSON.stringify() |
| Protect against prototype pollution | Use a reviver function that strips __proto__ |
| Limit request body size | Set express.json({ limit: '1mb' }) |
| Limit nesting depth | Check depth before parsing |
| Prevent mass assignment | Whitelist allowed fields or use strict Zod schemas |
| Validate URLs in JSON | Allowlist domains, block private IP ranges |
| Hash sensitive data before storing | Use our Hash Generator |
Frequently Asked Questions
Is JSON.parse() safe to use with untrusted input?
JSON.parse() itself does not execute code, so it is safe from code injection. However, it is vulnerable to prototype pollution (via __proto__ keys), denial of service (via deep nesting), and it does not validate data shape. Always use a reviver function to strip prototype keys and validate the parsed data against a schema.
How do I protect a Node.js API from JSON-based attacks?
Set a request body size limit (1MB is reasonable for most APIs), validate all JSON input against Zod or Ajv schemas with strict mode (reject unknown fields), use a reviver function in JSON.parse to strip __proto__ keys, and never pass raw request body fields to database update operations. These four measures prevent the majority of JSON-based attacks.
Can JSON contain executable code?
No. Unlike YAML (which can deserialise objects and call constructors), JSON only supports strings, numbers, booleans, null, arrays, and objects. It cannot contain functions, class instances, or executable code. However, if you use eval() to parse JSON (never do this), then arbitrary code execution becomes possible. Always use JSON.parse().
What is the difference between JSON injection and SQL injection?
SQL injection exploits string concatenation in SQL queries to execute arbitrary database commands. JSON injection exploits string concatenation in JSON construction to insert additional fields or modify the structure. The prevention is the same concept: use parameterised/structured methods (prepared statements for SQL, JSON.stringify for JSON) instead of string concatenation.
Inspect suspicious JSON payloads
Use our free JSON Formatter to visualise JSON structure and spot unexpected keys. Hash sensitive data with our Hash Generator.
Need a security audit for your API? The Debuggers provides security-focused development and code review services.
Found this helpful?
Join thousands of developers using our tools to write better code, faster.