` appearing anywhere in the JSON value breaks out of the script context and can inject arbitrary HTML. The JSON value `{\"name\": \"\"}` serialized naively into ``, and `&` in JSON string values before embedding in HTML. Replace `<` with `<`, `>` with `>`, and `&` with `&` — these are valid JSON string escapes that JSON.parse() handles correctly on the client. In Node.js, use the `serialize-javascript` package or a custom replacer function. Additionally, set `Content-Type: application/json` and `X-Content-Type-Options: nosniff` on all JSON API responses to prevent MIME-type sniffing attacks — browsers will not execute a response served as `application/json` as a script even if injected into a script src attribute."}},{"@type":"Question","name":"How do I validate Content-Type for JSON APIs?","acceptedAnswer":{"@type":"Answer","text":"Always verify that incoming requests include `Content-Type: application/json` before calling `req.json()` or body parsing middleware. Some frameworks silently return an empty object or undefined when parsing a non-JSON body — for example, an attacker can send `Content-Type: text/plain` with a JSON-looking body to bypass middleware that only validates JSON Content-Type bodies. In Express, `express.json()` with `strict: true` (default) rejects non-object/array payloads and returns 400. For manual validation, check `req.headers[\"content-type\"]?.includes(\"application/json\")` before attempting parse. In Next.js App Router Route Handlers, add `if (!request.headers.get(\"content-type\")?.includes(\"application/json\")) return Response.json({ error: \"Invalid Content-Type\" }, { status: 415 })` at the top of POST/PUT/PATCH handlers. Also set `Content-Type: application/json; charset=UTF-8` on all responses and pair it with `X-Content-Type-Options: nosniff` to prevent browser MIME sniffing. The 415 Unsupported Media Type status code is the correct HTTP response for wrong Content-Type."}},{"@type":"Question","name":"How do I defend against MongoDB query injection via JSON?","acceptedAnswer":{"@type":"Answer","text":"MongoDB query injection via JSON requires 3 layers of defense. First, validate field types at the API boundary with a schema library — `z.object({ username: z.string(), password: z.string() })` rejects `{\"password\": {\"$gt\": \"\"}}` with a type error before it reaches any query. Second, avoid passing user input directly as query field values — use $eq explicitly: `{ username: { $eq: req.body.username } }` rather than `{ username: req.body.username }`. The $eq operator accepts only scalars and will return no results for an object value. Third, never use the $where operator with user-supplied strings — $where evaluates JavaScript server-side (deprecated in MongoDB 4.4, disabled in Atlas by default) and is a direct code injection vector. Mongoose adds implicit $eq casting when a schema is defined (string schema fields reject object values), so using Mongoose schemas with strict mode (`strict: true`, the default) provides partial automatic protection, though explicit Zod validation at the request boundary remains best practice."}}]}]}

JSON Injection: Attacks, NoSQL Injection, Prototype Pollution & Safe Parsing

Last updated:

JSON injection attacks exploit unsafe construction of JSON strings — when user input is concatenated directly into a JSON string rather than serialized with JSON.stringify, an attacker can inject JSON control characters to modify the structure. The fix is always JSON.stringify(value) for any user-controlled data. NoSQL injection targets MongoDB and similar databases: passing {"$gt": ""} as a field value matches all documents when the code treats the JSON object as a scalar. Prototype pollution occurs when parsed JSON is merged into objects with Object.assign or spread — {"__proto__": {"isAdmin": true}} can add properties to Object.prototype when deeply merged. JSON.parse itself is safe — it never executes code — but downstream processing of the parsed object can be vulnerable. This guide covers JSON string injection, NoSQL injection via JSON operators, prototype pollution prevention, safe JSON construction patterns, Content-Type validation, and schema validation as the defense-in-depth layer.

JSON String Injection: Concatenation vs JSON.stringify

JSON string injection occurs the moment a developer writes '{"user":"' + input + '"}'. JSON control characters — ", {, }, [, ], :, , — are valid in any string value, so input containing those characters reshapes the document. The attacker's goal is to close the current string or object and inject new keys or values. There is one correct fix: build objects first, serialize last with JSON.stringify().

// ── VULNERABLE: string concatenation ─────────────────────────────
// Input: admin"} — closes the string and the object
const input = 'admin"}'
const json = '{"user":"' + input + '"}'
// → {"user":"admin"} — the trailing part is discarded or causes a parse error
// but with a crafted payload the attacker can inject arbitrary keys:

const payload = 'x","role":"admin'
const json2 = '{"user":"' + payload + '"}'
// → {"user":"x","role":"admin"}
// The attacker added a second key without touching the server

// ── SAFE: always use JSON.stringify for values ─────────────────
const safeJson = JSON.stringify({ user: payload })
// → {"user":"x\",\"role\":\"admin"}   — control chars escaped
// JSON.stringify escapes " → \", \ → \\, and all control chars

// ── Real-world patterns to avoid vs fix ───────────────────────

// AVOID: template literals with raw user input
const bad1 = `{"query":"${userInput}"}`

// SAFE: build the object, serialize it
const good1 = JSON.stringify({ query: userInput })

// AVOID: manual string building in logging
const bad2 = '{"event":"login","user":"' + username + '","ip":"' + ip + '"}'

// SAFE: object-first approach
const good2 = JSON.stringify({ event: 'login', user: username, ip })

// ── JSON.stringify escapes these characters automatically ──────
// Double quote   "   → \"
// Backslash      \   → \\
// Newline        \n  → \n
// Carriage ret.  \r  → \r
// Tab            \t  → \t
// Unicode U+0000–U+001F → \u00XX

// ── Verifying the fix with dangerous inputs ────────────────────
const dangerous = [
  '"}{"admin":true',          // object injection
  '\\",\"key\":\"value', // nested injection
  '\u0000\u0008\u001f',    // control characters
]

for (const d of dangerous) {
  const serialized = JSON.stringify({ value: d })
  const parsed = JSON.parse(serialized)
  console.assert(parsed.value === d, 'round-trip must be exact')
  console.assert(Object.keys(parsed).length === 1, 'must have exactly 1 key')
}
// All assertions pass — JSON.stringify is immune to these inputs

The vulnerability appears in any language that constructs JSON via string operations: PHP json_encode() is safe but string concatenation is not; Python json.dumps() is safe but f-strings are not; Go json.Marshal() is safe but fmt.Sprintf() is not. The fix is identical in every language: use the language's native JSON serialization function on the complete object rather than building the JSON string manually. Schema validation with Zod (z.string().max(500)) at the request boundary provides a second defense layer that limits the attack surface before serialization begins.

NoSQL Injection via JSON Operators in MongoDB

MongoDB queries are JSON documents. Query operators like $gt, $lt, $regex, $ne, and $in are expressed as JSON keys. When a server passes a user-supplied JSON object directly as a field value in a query, the attacker can substitute an operator object for the expected scalar. The result is that the query condition evaluates to true for all documents, bypassing authentication or authorization.

// ── VULNERABLE login endpoint (Node.js + MongoDB) ──────────────
import { Collection } from 'mongodb'

// Attacker sends: POST /login
// Body: {"username":"admin","password":{"$gt":""}}
async function loginVulnerable(db: Collection, body: unknown) {
  // No validation — body.password is {"$gt":""} (an object)
  const { username, password } = body as any
  const user = await db.findOne({ username, password })
  // MongoDB evaluates: password > ""
  // This is true for any non-empty string → returns admin document
  // Attacker is authenticated without knowing the password
}

// ── SAFE: Zod validation at the boundary ──────────────────────
import { z } from 'zod'

const LoginSchema = z.object({
  username: z.string().min(1).max(100),
  password: z.string().min(1).max(200),
})

async function loginSafe(db: Collection, body: unknown) {
  const result = LoginSchema.safeParse(body)
  if (!result.success) {
    throw new Error('Invalid input')
  }
  // result.data.password is guaranteed to be a string — never an object
  const { username, password } = result.data
  const user = await db.findOne({ username, password })
  return user
}

// ── SAFE: explicit $eq operator ────────────────────────────────
// Even without Zod, use $eq to prevent operator injection
async function loginWithEq(db: Collection, username: string, password: string) {
  const user = await db.findOne({
    username: { $eq: username }, // $eq only accepts scalar comparisons
    password: { $eq: password },
  })
  return user
}

// ── Other dangerous operator patterns ─────────────────────────
// $where evaluates JavaScript server-side — never use with user input
// db.users.find({ $where: "this.username == '" + input + "'" })
// Attacker input: ' || '1'=='1   → returns all documents (like SQL 1=1)

// $regex with user input — ReDoS risk
// db.users.find({ email: { $regex: userRegex } })
// Attacker: "(a+)+" → catastrophic backtracking, CPU exhaustion

// $in with unbounded user array — memory exhaustion
// db.users.find({ role: { $in: userRoles } })
// Attacker: array of 10M items

// ── Mongoose protection (partial) ─────────────────────────────
import mongoose from 'mongoose'

const UserSchema = new mongoose.Schema({
  username: { type: String, required: true },
  password: { type: String, required: true },
})
const User = mongoose.model('User', UserSchema)

// Mongoose applies type casting: schema String fields reject objects
// User.findOne({ password: {"$gt":""} }) throws CastError in Mongoose 6+
// BUT: Mongoose does not validate all operators — explicit Zod validation is safer

The $where operator deserves special mention: it evaluates a JavaScript string server-side, making it a direct code execution vector. MongoDB Atlas disables $where by default; on self-hosted MongoDB, add --noscripting to the server startup flags to disable server-side JavaScript entirely. For $regex, always validate that user-supplied regex strings are bounded — a pattern like (a+)+ causes catastrophic backtracking (ReDoS) and can peg a CPU at 100% indefinitely. Use Zod z.string().regex(/^[a-zA-Z0-9 ]+$/) to restrict what characters users can include in regex parameters.

Prototype Pollution via JSON Merge and Object.assign

Every JavaScript plain object inherits from Object.prototype. Adding a property to Object.prototype makes it visible on all objects created afterward — this is prototype pollution. JSON can carry __proto__ as a literal key, and certain merge operations treat it as a prototype accessor rather than a data key.

// ── Step 1: JSON.parse itself is SAFE ─────────────────────────
const parsed = JSON.parse('{"__proto__":{"isAdmin":true}}')
// parsed is a plain object: { "__proto__": { isAdmin: true } }
// parsed.__proto__ === Object.prototype  (inherited, not own)
// Object.getOwnPropertyDescriptor(parsed, '__proto__') exists as own prop

const obj = {}
console.log(obj.isAdmin) // undefined — prototype NOT polluted yet

// ── Step 2: Object.assign({}, parsed) CAN pollute ──────────────
Object.assign({}, parsed)
// Object.assign iterates own enumerable keys — includes "__proto__"
// Setting target["__proto__"] modifies Object.prototype in some engines
const obj2 = {}
// May log: true   (engine/version dependent — V8 protects against this
//                  in modern versions but don't rely on it)

// ── Step 3: Naive recursive merge is the real danger ──────────
function vulnerableMerge(target: any, source: any) {
  for (const key of Object.keys(source)) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] = target[key] ?? {}
      vulnerableMerge(target[key], source[key])
      // When key === "__proto__", target["__proto__"] = target.__proto__
      // Then recursing into target.__proto__ writes to Object.prototype
    } else {
      target[key] = source[key]  // target["__proto__"].isAdmin = true
    }
  }
}

const malicious = JSON.parse('{"__proto__":{"isAdmin":true}}')
const config = {}
vulnerableMerge(config, malicious)

const freshObj = {}
console.log((freshObj as any).isAdmin) // true — Object.prototype polluted!

// ── SAFE: block __proto__ and constructor keys ─────────────────
function safeMerge(target: Record<string, unknown>, source: Record<string, unknown>) {
  const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
  for (const key of Object.keys(source)) {
    if (BLOCKED_KEYS.has(key)) continue           // skip dangerous keys
    if (
      typeof source[key] === 'object' &&
      source[key] !== null &&
      !Array.isArray(source[key])
    ) {
      target[key] = target[key] ?? Object.create(null)  // null-prototype
      safeMerge(target[key] as Record<string, unknown>, source[key] as Record<string, unknown>)
    } else {
      target[key] = source[key]
    }
  }
  return target
}

// ── SAFE: structuredClone before merge ────────────────────────
// structuredClone deep-copies without prototype chain traversal
function safeDeepMerge(base: object, override: object) {
  const cleanOverride = structuredClone(override)  // Node 17+, all modern browsers
  return Object.assign(Object.create(null), base, cleanOverride)
}

// ── SAFE: use Object.create(null) for merge targets ───────────
// null-prototype objects have no __proto__ accessor to hijack
const safeTarget = Object.create(null)
// Object.assign(safeTarget, parsed) — safe because safeTarget has no prototype

// ── SAFE: JSON Schema validation blocks __proto__ ──────────────
import { z } from 'zod'
// z.object() only allows declared keys (or .passthrough() for extras)
// An undeclared __proto__ key is stripped by default
const SafeSchema = z.object({
  name: z.string(),
  settings: z.object({ theme: z.string() }),
})
// JSON.parse('{"__proto__":{"isAdmin":true},"name":"test"}')
// SafeSchema.parse(above) → { name: "test" } — __proto__ key discarded

CVE-2019-10744 (lodash < 4.17.12) is the canonical prototype pollution example: _.merge({}, JSON.parse('{"__proto__":{"isAdmin":true}}')) polluted Object.prototype.isAdmin globally. Lodash patched this by adding a key blocklist. When writing custom merge functions, always use Object.create(null) for intermediate objects and block __proto__, constructor, and prototype as keys. The simplest production-grade approach is to validate incoming JSON with Zod before any merge operation — z.object() strips undeclared keys by default, removing __proto__ before it ever reaches your merge logic.

Is JSON.parse Safe? What It Does and Doesn't Protect

JSON.parse() is safe from code execution — it only constructs data structures and never evaluates JavaScript expressions. This makes it categorically safer than eval(), which executes any valid JavaScript. But JSON.parse() does not protect against every attack: it faithfully creates whatever data structure the JSON describes, including objects with operator keys, deep nesting, or large arrays.

// ── JSON.parse vs eval — the critical difference ─────────────
// eval() EXECUTES code:
eval('{"key": alert(1)}')       // runs alert(1) — code execution
eval('process.exit(1)')          // exits the Node.js process
eval('"hello"')                  // returns "hello" (coincidentally safe)

// JSON.parse() only CONSTRUCTS data:
JSON.parse('{"key":"value"}')    // → { key: "value" } — no execution
JSON.parse('"hello"')            // → "hello"
JSON.parse('1+1')                // SyntaxError — not JavaScript, not JSON

// ── What JSON.parse does NOT protect against ──────────────────

// 1. Prototype pollution (as shown in Section 3)
const p1 = JSON.parse('{"__proto__":{"isAdmin":true}}')
// Safe at parse time, dangerous when merged recursively

// 2. Deeply nested "JSON bomb" — memory/CPU exhaustion
// 100,000 nested arrays: [[[[...]]]]
// JSON.parse will happily parse it, consuming O(n) stack/heap
function makeJsonBomb(depth: number): string {
  return '['.repeat(depth) + '1' + ']'.repeat(depth)
}
// JSON.parse(makeJsonBomb(100_000)) → may crash or OOM the process
// Mitigation: set a byte limit before calling JSON.parse
function safeParse(raw: string, maxBytes = 1_000_000): unknown {
  if (Buffer.byteLength(raw, 'utf8') > maxBytes) {
    throw new Error('JSON payload too large')
  }
  return JSON.parse(raw)
}

// 3. MongoDB operator injection (as shown in Section 2)
const p2 = JSON.parse('{"password":{"$gt":""}}')
// p2 is a valid object — the danger is passing p2.password to a MongoDB query

// 4. NoSQL/ORM operator injection via nested objects
// p2 is { password: { $gt: "" } } — JSON.parse did its job correctly

// ── JSON.parse IS safe from ────────────────────────────────────
// Code execution:          never evaluates JavaScript
// Remote code inclusion:   no import/require/fetch
// Prototype chain access:  does not call setPrototypeOf
// File system access:      no I/O
// Network access:          no I/O

// ── Reviver function — additional parse-time filtering ─────────
function blockOperatorKeys(key: string, value: unknown): unknown {
  if (typeof key === 'string' && key.startsWith('$')) {
    return undefined  // strip MongoDB operator keys at parse time
  }
  return value
}

const sanitized = JSON.parse('{"username":"admin","password":{"$gt":""}}', blockOperatorKeys)
// → { username: "admin", password: {} }
// The $gt key was stripped by the reviver — MongoDB query is now safe

The JSON bomb (deeply nested structure) deserves specific attention in public APIs. A 1 MB string of [[[[ repeated 100,000 times causes JSON.parse() to allocate a deeply nested array tree that consumes far more than 1 MB of heap and may exhaust the call stack. Always validate payload size before parsing — express.json() has a default limit of 100 KB (limit: '100kb'), and Next.js App Router Route Handlers have a configurable body size limit. Set limit to the smallest value your API actually needs — typically 10 KB to 100 KB for structured data payloads.

Content-Type and Request Validation

JSON APIs have two Content-Type obligations: accepting only application/json from clients and serving application/json to clients. Both sides have security implications. A missing or wrong inbound Content-Type can allow attackers to bypass body-parsing middleware. A missing outbound Content-Type can trigger browser MIME sniffing, which may cause a JSON response to be interpreted as HTML.

// ── Express: enforce inbound Content-Type ─────────────────────
import express, { Request, Response, NextFunction } from 'express'

const app = express()

// express.json() middleware: only parses bodies with Content-Type: application/json
// Requests with a different Content-Type are NOT parsed → req.body is undefined
app.use(express.json({
  limit: '50kb',      // reject bodies larger than 50 KB
  strict: true,       // only accept arrays and objects at root level
}))

// Explicit 415 for non-JSON requests
function requireJson(req: Request, res: Response, next: NextFunction) {
  const ct = req.headers['content-type'] ?? ''
  if (['POST', 'PUT', 'PATCH'].includes(req.method) && !ct.includes('application/json')) {
    res.status(415).json({ error: 'Content-Type must be application/json' })
    return
  }
  next()
}

app.use(requireJson)

// ── Next.js App Router: Content-Type check ────────────────────
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  const contentType = request.headers.get('content-type') ?? ''
  if (!contentType.includes('application/json')) {
    return NextResponse.json(
      { error: 'Expected Content-Type: application/json' },
      { status: 415 }
    )
  }

  let body: unknown
  try {
    body = await request.json()
  } catch {
    return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
  }

  // Validate the parsed body with Zod
  // ...
}

// ── Outbound: always set Content-Type on JSON responses ────────
// Express res.json() sets Content-Type: application/json automatically
// Next.js NextResponse.json() sets it automatically
// Manual fetch Response:
new Response(JSON.stringify(data), {
  headers: {
    'Content-Type': 'application/json',
    'X-Content-Type-Options': 'nosniff', // prevents MIME sniffing
  },
})

// ── Security headers for JSON APIs ───────────────────────────
app.use((_req, res, next) => {
  res.setHeader('X-Content-Type-Options', 'nosniff')
  res.setHeader('Cache-Control', 'no-store')            // don't cache sensitive responses
  res.removeHeader('X-Powered-By')                      // don't expose server info
  next()
})

// ── CORS for JSON APIs: restrict origin ───────────────────────
import cors from 'cors'
app.use(cors({
  origin: ['https://yourdomain.com'],   // explicit allowlist
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
}))

A subtle attack involves sending a request with Content-Type: text/plain (which does not trigger a CORS preflight for simple requests) but with a JSON-shaped body. If the server parses the body without validating Content-Type, the attacker can make cross-origin write requests from a victim's browser without triggering CORS. This is known as a CSRF-via-JSON attack. The defense is requiring Content-Type: application/json — browsers cannot set this header on cross-origin requests without a preflight, which is blocked by CORS. Combine this with a SameSite=Strict cookie policy for authenticated endpoints.

Schema Validation as Defense-in-Depth

Schema validation is the most comprehensive defense against JSON injection vectors. A Zod or Ajv schema at the API boundary enforces types, restricts keys, limits string lengths, and blocks operator objects — all before the data reaches any downstream consumer (database, template, merge function). Validation at the boundary is always cheaper than sanitizing in 10 different places downstream.

import { z } from 'zod'

// ── Define schemas at the API boundary ────────────────────────
// This schema defends against:
// 1. JSON string injection (type: z.string() rejects objects)
// 2. MongoDB operator injection (z.string() rejects { $gt: "" })
// 3. Prototype pollution (__proto__ key is not declared, stripped by default)
// 4. JSON bomb via nesting (z.object() has max depth from schema structure)

const LoginSchema = z.object({
  username: z.string().min(1).max(100),
  password: z.string().min(8).max(200),
})

const UserUpdateSchema = z.object({
  displayName: z.string().min(1).max(100).optional(),
  bio:         z.string().max(2000).optional(),
  // Note: no __proto__, no role, no isAdmin — Zod strips undeclared keys
})

const SearchSchema = z.object({
  query: z.string().min(1).max(200)
    .regex(/^[a-zA-Z0-9 -_.,!?'"]+$/, 'Query contains invalid characters'),
  page:  z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
})

// ── Use safeParse at every API boundary ───────────────────────
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  let raw: unknown
  try {
    raw = await request.json()
  } catch {
    return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
  }

  const result = LoginSchema.safeParse(raw)
  if (!result.success) {
    return NextResponse.json(
      { error: 'Validation failed', details: result.error.flatten().fieldErrors },
      { status: 422 }
    )
  }
  // result.data is typed and safe:
  // { username: string, password: string }
  // No injection vectors remain
}

// ── Ajv alternative for performance-critical paths ────────────
import Ajv from 'ajv'
const ajv = new Ajv({ allErrors: true })

const loginSchema = {
  type: 'object',
  properties: {
    username: { type: 'string', minLength: 1, maxLength: 100 },
    password: { type: 'string', minLength: 8, maxLength: 200 },
  },
  required: ['username', 'password'],
  additionalProperties: false,  // strips all undeclared keys including __proto__
}

const validateLogin = ajv.compile(loginSchema)
// Compiled once, runs at ~10M validations/sec
// additionalProperties: false strips __proto__ and operator keys

// ── Defense matrix ────────────────────────────────────────────
// Attack vector          | JSON.stringify | Zod/Ajv | $eq operator
// ---------------------- | -------------- | ------- | ------------
// JSON string injection  | ✅ prevents    | ✅       | N/A
// MongoDB $gt injection  | ❌ type only   | ✅       | ✅
// Prototype pollution    | N/A            | ✅       | N/A
// JSON bomb (deep nest)  | N/A            | partial  | N/A
// ReDoS via $regex       | N/A            | ✅       | N/A

Zod's z.object() strips undeclared keys by default (equivalent to additionalProperties: false in JSON Schema). This means a __proto__ key in the input is silently discarded before the validated data is returned. Combined with JSON.stringify() for output construction and explicit z.string() types for database field values, the schema validation layer eliminates all three primary JSON injection vectors in a single pass.

Sanitizing JSON Output: Preventing XSS in JSON Responses

When a server embeds JSON data into an HTML page — inside a <script> tag or a data attribute — the JSON string can contain characters that break out of the HTML context. The most dangerous is </script>, which closes the script block and allows the attacker to inject raw HTML. The second vector is serving JSON responses without the correct Content-Type, allowing browsers to MIME-sniff and interpret the body as HTML.

// ── Vulnerability: inline JSON in HTML script tag ─────────────
// The value "</script><img src=x onerror=alert(1)>" in a JSON property
// will break out of the <script> block when inlined naively:

// UNSAFE: naive inline JSON in HTML
// const html = `<script>const DATA = ${JSON.stringify(serverData)}</script>`
// If serverData.name = '</script><img src=x onerror=alert(1)>'
// The output becomes:
// <script>const DATA = {"name":"</script>   ← closes the script block here
// <img src=x onerror=alert(1)>              ← attacker's HTML is executed

// ── SAFE: escape HTML-dangerous characters in JSON strings ─────
function htmlSafeJsonStringify(data: unknown): string {
  return JSON.stringify(data)
    .replace(/</g, '\u003c')    // < → <
    .replace(/>/g, '\u003e')    // > → >
    .replace(/&/g, '\u0026')    // & → &
    .replace(/
/g, '\u2028') // LINE SEPARATOR — breaks JS
    .replace(/
/g, '\u2029') // PARAGRAPH SEPARATOR — breaks JS
}

// Result for the dangerous input:
// {"name":"\u003c/script\u003e\u003cimg src=x onerror=alert(1)\u003e"}
// The script block is NOT closed — JSON.parse() on the client recovers
// the original string correctly

// ── Using serialize-javascript (npm package) ───────────────────
// serialize-javascript handles all edge cases including Functions and RegExp
import serialize from 'serialize-javascript'

const safeJson = serialize(serverData, { isJSON: true })
// Escapes </script>, 
, 
, and HTML special chars

// ── Next.js / React pattern ────────────────────────────────────
// React's dangerouslySetInnerHTML requires explicit opt-in
// For JSON data: use data attributes or a safe serialize approach

// In a Server Component (safe — React handles escaping for script tags):
// <script
//   id="server-data"
//   type="application/json"
//   dangerouslySetInnerHTML={{ __html: htmlSafeJsonStringify(serverData) }}
// />

// Client reads it:
// const data = JSON.parse(document.getElementById('server-data').textContent)

// ── JSON API responses: Content-Type protection ────────────────
// Always set Content-Type: application/json on JSON API responses
// Browsers will not interpret application/json as HTML or script
// Even if the JSON contains <script> tags, they are not executed

// Express: res.json() sets Content-Type automatically
// Next.js: NextResponse.json() sets Content-Type automatically
// Manual:
new Response(JSON.stringify(data), {
  headers: {
    'Content-Type': 'application/json',
    'X-Content-Type-Options': 'nosniff',   // no MIME sniffing
    'Content-Security-Policy': "default-src 'none'", // for pure API responses
  },
})

// ── JSONP is not safe — avoid it entirely ─────────────────────
// JSONP wraps JSON in a callback: callback({"data":"value"})
// An attacker can host a page that calls your JSONP endpoint
// and the browser executes the callback — leaking user data
// Use CORS instead. JSONP has no safe implementation.

The Unicode characters U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR) are valid in JSON strings but invalid as raw characters in JavaScript string literals in some older engines — they terminate the string context. Always replace them with their \u escape sequences when embedding JSON in HTML. The htmlSafeJsonStringify function above handles all five dangerous characters. In production Next.js or React applications, avoid inlining JSON in script tags entirely — pass server data through React Server Component serialization or type="application/json" script tags with the safe serializer.

Key Terms

JSON injection
A class of vulnerability where attacker-controlled input is concatenated into a JSON string without proper escaping, allowing the attacker to modify the JSON document structure. JSON control characters (", {"{"}, {"}"}, [, ], :, ,, \) embedded in raw user input can close strings, add keys, or inject objects. Prevented entirely by using JSON.stringify() for all user-controlled values instead of string concatenation.
NoSQL injection
An attack against document databases like MongoDB where user-controlled JSON objects containing query operators ($gt, $regex, $where) are passed directly to query methods. Because MongoDB queries are JSON, a user who can supply an object value where a scalar is expected can make the query condition evaluate to true for all documents, bypassing authentication or authorization. Prevented by validating that field values are the expected primitive types (string, number) before use in queries, or by using explicit $eq operators.
prototype pollution
An attack that modifies Object.prototype by assigning properties via a __proto__ key in a recursive object merge. Because all plain JavaScript objects inherit from Object.prototype, a polluted prototype makes injected properties (like isAdmin: true) appear on every subsequently created object. JSON.parse() is safe; the danger lies in downstream recursive merge functions that traverse __proto__ as a data key. Prevented by blocking __proto__ and constructor keys in merge functions, using Object.create(null) targets, or validating input with Zod before merging.
JSON bomb
A denial-of-service payload consisting of deeply nested JSON arrays or objects, designed to consume excessive memory or CPU during parsing. A string of 100,000 nested arrays like [[[[...]]]] is a small string that expands into an enormous data structure when parsed. JSON.parse() faithfully processes it, potentially exhausting heap or stack. Mitigated by enforcing payload size limits before calling JSON.parse() — set limit: '100kb' in Express express.json() or validate Buffer.byteLength(body) manually.
ReDoS (Regular Expression Denial of Service)
An attack that exploits catastrophic backtracking in regular expression engines by supplying a crafted string that causes exponential-time matching. In the context of JSON APIs, it occurs when user-supplied strings are passed to regex-based validation without first sanitizing them, or when MongoDB's $regex operator receives an attacker-controlled pattern like (a+)+. Prevented by restricting user-supplied values with an allowlist character class (e.g., z.string().regex(/^[a-zA-Z0-9 ]+$/)) before they reach any regex evaluation.
Content-Type sniffing
A browser behavior where the MIME type of a response is inferred from the content rather than the declared Content-Type header. If a server returns JSON without Content-Type: application/json, a browser may interpret the body as HTML and execute any script tags found in the JSON string. Prevented by always setting the correct Content-Type header and adding X-Content-Type-Options: nosniff to prevent browsers from overriding it.

FAQ

What is JSON injection and how does it work?

JSON injection occurs when user-controlled input is concatenated directly into a JSON string rather than being serialized by a safe method like JSON.stringify(). Because JSON uses control characters — double quotes, curly braces, square brackets, and colons — to define structure, an attacker who can inject those characters can alter the JSON document's shape. For example, if a server builds a JSON body with the template {"'{"user":"' + input + '"}'}" and the input contains "}{, the attacker closes the string and the object early, then appends additional keys. The attack is exploitable in any context where raw string concatenation constructs JSON: HTTP response bodies, log entries, database queries, and template strings. The attack is 100% preventable by always passing user values through JSON.stringify() rather than interpolating them as raw strings.

How do I prevent JSON injection attacks?

The only correct fix is to never concatenate raw user input into a JSON string. Use JSON.stringify(value) for any user-controlled data — it escapes all JSON control characters automatically, including double quotes, backslashes, and all control characters (U+0000 to U+001F). Build JavaScript objects first, then serialize the complete object: instead of string concatenation, write JSON.stringify(userObj) where userObj is a plain JS object. This approach is immune to injection regardless of what the value contains. As a second layer, validate all inputs with Zod (z.string().max(200)) before serialization. Never use template literals or string concatenation to build JSON — there is no reliable manual escaping approach that beats JSON.stringify().

What is NoSQL injection via JSON?

NoSQL injection via JSON targets databases that accept JSON objects as query parameters — most notably MongoDB. MongoDB's query operators ($gt, $lt, $regex, $where, $ne) are expressed as JSON keys. If a server passes user-supplied JSON directly to a query without validation, an attacker can supply operator objects instead of scalar values. The classic example: a login endpoint that queries db.users.findOne({ username, password }) where password is {"$gt": ""} — MongoDB evaluates password > "" which is true for any non-empty string, bypassing the password check. Prevention requires validating that field values are the expected primitive types (z.string()) before passing them to query methods — Zod rejects operator objects at the boundary.

How does prototype pollution via JSON work?

Prototype pollution occurs when attacker-controlled JSON containing __proto__ keys is merged into a plain JavaScript object using a recursive merge function. Every plain object in JavaScript inherits from Object.prototype, so properties added to it are visible on all objects. JSON.parse('{"__proto__":{"isAdmin":true}}') itself is safe — the parsed object contains a literal key "__proto__" but does not modify the prototype. The danger is downstream: a naive recursive merge(target, parsed) that assigns target["__proto__"].isAdmin = true writes to Object.prototype directly. CVE-2019-10744 (lodash < 4.17.12) is a real-world example. Prevention: block __proto__ and constructor keys in merge functions, use Object.create(null) targets, or validate with Zod before merging.

Is JSON.parse vulnerable to code injection?

No. JSON.parse() is not vulnerable to code injection. It only creates JavaScript data structures — objects, arrays, strings, numbers, booleans, null — and never evaluates code, calls functions, or executes expressions. This distinguishes it from eval(), which executes any JavaScript expression. A malicious JSON string parsed with JSON.parse()produces a plain object with string values — the server process is not affected regardless of the string content. The security risks associated with JSON parsing are not in the parse step but in downstream processing: recursive merge functions (prototype pollution), MongoDB queries (NoSQL injection), or HTML embedding without escaping (XSS). Always validate the parsed object's shape with a schema before passing it to any downstream consumer.

How do I sanitize JSON output to prevent XSS?

JSON embedded in HTML pages is a common XSS vector. The string </script> appearing in a JSON value breaks out of the surrounding <script> tag and can inject arbitrary HTML. The fix: escape the 5 dangerous characters before inlining JSON in HTML. Replace < with \u003c, > with \u003e, & with \u0026, and U+2028/U+2029 with their Unicode escape forms. These are valid JSON string escapes that JSON.parse() handles correctly on the client. For JSON API responses (not embedded in HTML), always set Content-Type: application/json and X-Content-Type-Options: nosniff — browsers will not execute a response served as application/json as a script.

How do I validate Content-Type for JSON APIs?

Always verify that incoming requests include Content-Type: application/json before parsing the body. Some frameworks return an empty object or undefined when parsing a non-JSON body — allowing attackers to bypass middleware that only validates JSON Content-Type bodies. In Express, express.json() rejects non-JSON Content-Types automatically; add an explicit middleware function that returns HTTP 415 Unsupported Media Type for POST/PUT/PATCH requests without the correct Content-Type header. In Next.js App Router, check request.headers.get("content-type")?.includes("application/json") at the top of mutation handlers. On outbound responses, res.json() and NextResponse.json() set Content-Type: application/json automatically; add X-Content-Type-Options: nosniff to prevent browser MIME sniffing.

How do I defend against MongoDB query injection via JSON?

MongoDB query injection requires 3 defense layers. First, validate field types at the API boundary with a schema library — z.object({ username: z.string(), password: z.string() }) rejects {"password": {"$gt": ""}} with a type error before it reaches any query. Second, use explicit $eq operators: { username: { $eq: req.body.username } } rather than { username: req.body.username }$eq accepts only scalars and returns no results for an object value. Third, never use the $where operator with user-supplied strings — it evaluates JavaScript server-side and is a direct code injection vector (disabled in MongoDB Atlas by default). Mongoose schemas with strict: true provide partial protection via type casting, but explicit Zod validation at the request boundary remains best practice.

Validate your JSON schema online

Paste any JSON to validate structure, detect issues, and test against a schema in Jsonic's validator — no setup required.

Open JSON Schema Validator

Further reading and primary sources