JSON Security Best Practices: How to Protect Your App From Real Attacks
In 2021, a popular npm package with 7 million weekly downloads was found vulnerable to prototype pollution. The fix was one line. The damage was months of incident response.
JSON is not dangerous. How we handle JSON is dangerous. This guide covers the vulnerabilities that actually appear in production - not theoretical edge cases, but the mistakes that have caused real breaches.
JSON Injection
JSON injection happens when untrusted input is concatenated directly into a JSON string without proper escaping. This is the JSON equivalent of SQL injection:
// VULNERABLE: String concatenation to build JSON
const userInput = 'Alice", "role": "admin", "extra": "';
const json = '{"name": "' + userInput + '", "role": "viewer"}';
// Result:
// {"name": "Alice", "role": "admin", "extra": "", "role": "viewer"}
// Most parsers use the LAST occurrence of a key, so role = "viewer"
// But some parsers use the FIRST - role = "admin"The fix is simple: never construct JSON by string concatenation. Always use JSON.stringify():
// SAFE: Let the serializer handle escaping
const data = {
name: userInput,
role: "viewer"
};
const json = JSON.stringify(data);
// Special characters in userInput are properly escapedPrototype Pollution
Prototype pollution is a JavaScript-specific attack where an attacker injects properties into Object.prototype through JSON input. Any code that recursively merges objects without safeguards is potentially vulnerable:
// A naive deep merge function
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === "object" && source[key] !== null) {
target[key] = target[key] || {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Attacker sends this JSON:
const malicious = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, malicious);
// Now EVERY object in the application has isAdmin = true
const user = {};
console.log(user.isAdmin); // trueDefenses include:
- Check for
__proto__,constructor, andprototypekeys and reject or skip them during object merging. - Use
Object.create(null)for objects that store untrusted data - these have no prototype chain to pollute. - Freeze the prototype:
Object.freeze(Object.prototype)prevents any modifications, though this can break libraries that rely on prototype extension. - Use
Mapinstead of plain objects for user-controlled key-value data.
Unsafe Deserialization
Some JSON-like parsers in other languages can instantiate arbitrary objects during deserialization. This is extremely dangerous because it can lead to remote code execution.
Python: Never use yaml.load() (which can execute arbitrary Python) when you mean yaml.safe_load(). For JSON specifically, Python's json module is safe - it only produces basic types.
Java: Libraries like Jackson can be configured to instantiate arbitrary classes based on type hints in the JSON. Always disable polymorphic deserialization or restrict it to a whitelist of allowed types:
// DANGEROUS: Unrestricted polymorphic deserialization
objectMapper.enableDefaultTyping();
// SAFE: Restrict to specific base types
objectMapper.activateDefaultTyping(
objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);Never Use eval() for JSON
This one should be well-known by now, but it still appears in production code:
// NEVER DO THIS
const data = eval("(" + jsonString + ")");
// jsonString could contain:
// (function() { /* malicious code */ })()
// ALWAYS use JSON.parse()
const data = JSON.parse(jsonString);JSON.parse() only understands the JSON grammar. It cannot execute functions, access variables, or perform any computation. It either returns a valid JavaScript value or throws a SyntaxError. There is no scenario where eval() is the right choice for parsing JSON.
Preventing Data Leaks in API Responses
One of the most common JSON security mistakes isn't a parsing vulnerability - it's returning too much data. Serializing a full database object directly to JSON exposes internal fields that clients shouldn't see:
// DANGEROUS: Serializing the full user object
app.get("/api/users/:id", async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json(user);
// Exposes: passwordHash, internalNotes, ssn, stripeCustomerId, etc.
});
// SAFE: Explicitly select fields to return
app.get("/api/users/:id", async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
createdAt: user.createdAt
});
});Use a serialization layer or DTO (Data Transfer Object) pattern consistently. Never rely on database field exclusion alone - a schema migration that adds a sensitive column will automatically leak it if you're serializing whole objects.
Content-Type Validation
Your server should validate the Content-Type header on incoming requests and reject anything unexpected. Accepting JSON from a request with a text/plain content type can bypass CORS preflight checks:
// Express middleware to enforce JSON Content-Type
function requireJson(req, res, next) {
if (req.method !== "GET" && req.method !== "DELETE") {
const contentType = req.headers["content-type"];
if (!contentType || !contentType.includes("application/json")) {
return res.status(415).json({
error: "Unsupported Media Type. Expected application/json"
});
}
}
next();
}CORS preflight requests (the OPTIONS check) are only triggered for certain content types. application/json triggers a preflight; text/plaindoes not. An attacker can exploit this difference to send cross-origin requests with JSON payloads by using a "simple" content type. Strict Content-Type validation on the server blocks this vector.
Rate Limiting and Resource Exhaustion
JSON APIs that accept complex input are vulnerable to resource exhaustion attacks. An attacker can send deeply nested JSON, extremely large payloads, or high volumes of requests to overwhelm your server:
// Set a request body size limit
app.use(express.json({ limit: "1mb" }));
// Validate nesting depth
function checkDepth(obj, maxDepth = 10, current = 0) {
if (current > maxDepth) {
throw new Error("JSON nesting depth exceeds maximum");
}
if (typeof obj === "object" && obj !== null) {
for (const value of Object.values(obj)) {
checkDepth(value, maxDepth, current + 1);
}
}
}A particularly nasty attack is sending a JSON payload like {"a":{"a":{"a":{"a":...}}}} nested hundreds of levels deep. Some parsers handle this fine; others will blow the stack or consume excessive memory. Always enforce a body size limit and consider limiting nesting depth for untrusted input.
Input Validation After Parsing
Parsing JSON successfully doesn't mean the data is valid. Always validate the structure and content after parsing:
import { z } from "zod";
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(["viewer", "editor", "admin"]).default("viewer")
});
app.post("/api/users", (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: "Validation failed",
details: result.error.issues
});
}
// result.data is typed and validated
createUser(result.data);
});JWT Security Considerations
JSON Web Tokens are base64-encoded JSON payloads with a signature. A few critical security points:
- Never trust the payload without verifying the signature. The JWT body is base64-encoded, not encrypted. Anyone can decode and read it. Anyone can modify it. The signature is the only thing that proves authenticity.
- Reject the "none" algorithm. Some JWT libraries accept
{"alg": "none"}in the header, which skips signature verification entirely. Always whitelist acceptable algorithms. - Don't store sensitive data in JWT payloads.JWTs are not encrypted by default (JWE exists but is less common). Don't put passwords, API keys, or PII in the payload.
- Set short expiration times.JWTs can't be revoked once issued (without a server-side blocklist), so short expiration times limit the damage window.
// Verifying a JWT securely
import jwt from "jsonwebtoken";
try {
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"], // Whitelist allowed algorithms
issuer: "auth.example.com",
audience: "api.example.com"
});
} catch (err) {
// Token is invalid, expired, or tampered with
return res.status(401).json({ error: "Invalid token" });
}Security Checklist
A quick reference for securing JSON in your applications:
- Use
JSON.parse(), nevereval() - Build JSON with serializers, never string concatenation
- Block
__proto__andconstructorin object merging - Return explicit field lists in API responses, not raw database objects
- Validate
Content-Type: application/jsonon incoming requests - Enforce body size limits and nesting depth limits
- Validate all parsed JSON against a schema before processing
- Wrap JSON responses in objects, not bare arrays
- Verify JWT signatures and whitelist algorithms
- Rate-limit all public-facing JSON endpoints
JSON itself is a simple, safe format. The vulnerabilities come from how it's constructed, parsed, merged, and exposed. For structural validation, consider JSON Schema to enforce data contracts. And for a quick reference on syntax issues that cause parsing failures, see our guide to common JSON errors.
100% Client-Side - Your Data Stays Private
Because our tool runs entirely in your browser, sensitive JSON payloads never touch a server. Paste confidential data with confidence.
Open JSON Prettifier