JSON Security Mistakes in Apps and Pipelines and Their Fixes
A tested guide to JSON security: where bodies turn hostile, parser differences, prototype pollution, SQL, limits, numbers, logs, and a checklist.

JSON is not dangerous. It is a data format, with no way to run code or open a file. Every incident that involves JSON is really an incident about what a program did with the data after it read it: it trusted a number, concatenated a string into a query, copied a key into an object, logged the whole thing, or let a stranger decide how much memory it would use. The format is the delivery vehicle. The bug is on the receiving end.
That is why a guide about JSON security is mostly a guide about trust boundaries. Wherever a document crosses from something you do not control to something you do (an HTTP request, a webhook, a file upload, a message queue, a third-party API, a partner's export) there is a parse, then some code that uses the result. This guide walks through the places that go wrong, in the order a request meets them, with the experiments I ran for each. Each section links to a shorter article with the full details.
Where JSON enters, and what each step trusts
Follow one request through a typical service. The body arrives as bytes, so something decides how large it may be. A parser turns bytes into objects, so something decides what counts as valid and how deep it may go. The parsed object is validated, merged with defaults, stored, logged and sometimes passed on to another service. Each step has an assumption in it:
The size step assumes the body is about as big as the usual ones.
The parse step assumes the text means the same thing to every system that reads it.
The merge step assumes keys are data, not instructions.
The query step assumes strings stay strings.
The log step assumes the object contains nothing that must not be kept.
Attackers do not need to break the format. They need one assumption that does not hold, and a JSON body lets them control every value and every name in the document. The rest of this guide takes the steps in order.
Step 1: bound the input before you parse it
A parser will read as much as it is given. Parsing costs memory that is a multiple of the text, and the multiple depends on the shape. For ordinary records, Python used about 4.5 times the text size (a million records, 85.8 MB of text, 384 MB of heap). An array of five million empty objects was 15 MB of text and 364 MB of heap, 24 times as much. A 20 MB array of zeros took 89 MB. The same number of megabytes can cost very different amounts, and the sender picks the shape (limiting depth and size of incoming JSON).
The first control is a body size limit at the earliest point that sees the request, before any code reads it. A reverse proxy does this best: nginx rejects over client_max_body_size (1 MB by default, from the nginx documentation) and the application never sees the request. Frameworks have their own settings, such as Express's limit for express.json() (100 kb by default in its documentation). I cite those defaults and did not test the servers, so test yours with a request one byte over the limit and look for the 413. Choose the number from the largest legitimate body you have seen, add margin, and give a bulk endpoint its own larger limit instead of raising everyone's.
Depth is a separate limit, because depth is not size. A body of 10,000 opening brackets followed by 10,000 closing ones is 20 KB and valid JSON. What happens next depends on the platform:
parser 5,000 levels 100,000 levels
Python json.loads ok RecursionError
orjson.loads JSONDecodeError JSONDecodeError
Node JSON.parse ok ok (1,000,000 levels, 136 ms)
PHP json_decode (default) error at 512 error
jq 1.6 refused above 128 refusedThe parser's answer is not the end of it. Node's parser accepted a million levels, and then JSON.stringify failed with a RangeError somewhere between 1,000 and 5,000 levels, structuredClone failed at 200,000, and a three line recursive function did too. Python parsed 5,000 levels and copy.deepcopy raised RecursionError on the result, while json.dumps and == still worked. A schema that references itself failed on a 2,000 level body. Every recursive function that touches the parsed object is a place where a small body becomes an exception (denial of service through deeply nested JSON).
So measure depth on its own, in one pass over the bytes, before the parser, with a limit such as 32:
if len(body) > MAX_BYTES: reject(413)
check_depth(body, 32) # counts brackets, skips string contents
data = json.loads(body)A 33 level body was refused as soon as the 33rd opener was read, so the cost is bounded by the limit and not by the attack. Use a parser's own depth setting when it has one (PHP's third argument to json_decode, for example) and keep your own check as well, because the setting does not protect code that parses somewhere else.
Do not "fix" a RecursionError by raising Python's recursion limit. That moves the failure from an exception to a crash of the interpreter. Fix the input.
Step 2: know what your parser accepts
JSON as specified is small. Parsers in practice accept more, differ in details, and disagree with each other. Each disagreement is a place where two systems that read the same text reach different conclusions, which is the root of a whole class of bypasses.
Duplicate keys
The standard says member names "SHOULD be unique" and does not say what to do otherwise. I fed one document with a duplicate key to eleven readers:
{"role":"user","role":"admin","id":9007199254740993,"big":12345678901234567890}reader role id big
Python json admin 9007199254740993 12345678901234567890
orjson admin 9007199254740993 12345678901234567890
Node JSON.parse admin 9007199254740992 12345678901234567000
PHP json_decode admin 9007199254740993 1.2345678901234567E+19 (float)
Ruby JSON.parse admin 9007199254740993 12345678901234567890
Go encoding/json admin 9.007199254740992e+15 1.2345678901234567e+19
jq 1.6 admin 9007199254740992 12345678901234567000
PostgreSQL jsonb admin 9007199254740993 12345678901234567890
PostgreSQL json (text) admin - -
MySQL 8.4 JSON admin 9007199254740993 12345678901234567890
SQLite 3.54 json_extract user 9007199254740993 1.2345678901234567e+19Ten of eleven kept the last value. SQLite's json_extract returned the first one. A gateway written in one language that validates role and a backend that reads it with another can end up on different sides of that table. The exploit is the shape "validator says user, application sees admin," and nothing in either component is a bug on its own.
The defense is to reject duplicates, because rejecting is the one answer no other parser can contradict. In Python:
def no_dups(pairs):
out = {}
for k, v in pairs:
if k in out:
raise ValueError(f"duplicate key {k!r}")
out[k] = v
return out
json.loads(text, object_pairs_hook=no_dups)Use it where the body makes an authorization decision. Go's encoding/json and PHP's json_decode have no such switch in the versions I used, and the usual fix is a pre-check or a strict library (handling untrusted JSON in Python safely covers Python's hooks).
Numbers
The same document shows the number problem. The id 9007199254740993 is 2^53 + 1. Node, jq and Go (with its default decoding into any) turned it into 9007199254740992, which is a different number that happens to compare equal to a neighbor. Python, Ruby, PHP, PostgreSQL and MySQL kept it exact. A service in one of the first group and a service in the second will disagree about which row an id refers to. A twenty digit integer came out as a float in PHP, in Go, and in SQLite, and as an inexact 12345678901234567000 in Node and jq.
Fixes: send large ids as strings, decode with an option that preserves digits (Go's UseNumber returned 9007199254740993 and 12345678901234567890 exactly, and PHP's JSON_BIGINT_AS_STRING returned the twenty digit value as a string), and range check anything that becomes arithmetic.
Python adds its own cases. NaN, Infinity and -Infinity are not JSON but json.loads accepts them, 1e999 becomes inf, a 5,000 digit integer raises ValueError from the 4,300 digit conversion limit (a 4,300 digit one is accepted), and a lone surrogate escape parses into a string that later raises UnicodeEncodeError when written as UTF-8. A parse_constant hook that raises handles the first group, and orjson rejected all of them (handling untrusted JSON in Python safely).
One more is a slow-motion denial of service. With parse_float=Decimal, a body containing 1e1000000 parses instantly, and int() of the result took 8.86 seconds and produced a 3.3 million bit integer. My first test run, which went to 1e99999999, did not finish in a minute. Check the exponent before doing arithmetic that creates an integer.
Parsing code instead of data
The worst parser choice is not to use a parser. eval on JSON text works on the sample, fails on true and null in Python, executes any expression in the text, and was three times slower in Node and seventeen times slower in Python than the real thing. A hostile value of __import__("os").getcwd() ran in Python's eval; the same text was rejected by json.loads. In JavaScript, eval also treats a literal __proto__ key as a prototype assignment, which JSON.parse does not (why you should not eval JSON).
Step 3: do not turn keys into instructions
Once the document is parsed, its keys are attacker-chosen strings. Code that uses them as names, whether of properties, columns, files or classes, is giving the sender a vocabulary.
Prototype pollution
In JavaScript, JSON.parse('{"__proto__":{"isAdmin":true}}') is harmless: it creates an own property named __proto__. A recursive merge of that object into a settings object is not. I ran a ten line merge that visits keys with for...in, and afterwards a new empty object reported isAdmin as true, in the whole process, until restart. Blocking the string __proto__ was not enough, because {"constructor":{"prototype":{...}}} reached the same place through two ordinary reads. Object.assign replaced the copy's prototype but not the global one, and the spread form was clean (how prototype pollution starts with a JSON parse).
What worked: skipping __proto__, constructor and prototype and checking Object.hasOwn; merging into Object.create(null); a reviver that returns undefined for the key; and a Map. The current lodash (4.18.1) merge was not vulnerable. Most vulnerable code is a home-grown deep merge.
The cure that scales is to validate the shape first. A schema with additionalProperties: false rejects unexpected keys before any merge sees them.
SQL
A JSON body brings two things a form did not: names as well as values, and code that loops over keys. Pasting a value into SQL returned all three users in my test table and a UNION returned every API key; binding the value as a parameter returned nobody and was the fix. Then the part parameters do not cover:
A PATCH handler that bound every value but built the column list from the JSON keys let a client send
{"role":"admin"}and become an administrator. This is mass assignment and needs no injection.A key written as
role = 'admin', emailproducedupdate users set role = 'admin', email = ? where id = ?, so the role changed even though every value was bound.A sort field taken from the body leaked a hidden fact through the order of the rows.
Identifiers cannot be bound. An allowlist of column names refused all three (JSON injection in SQL built from request bodies).
Step 4: validate the shape, then the rules
Between parse and use sits the layer that most decides whether an attack lands: validation. A schema states the contract in data, a typed model states it in code, and a database constraint states it where nothing can bypass it. The same payload with six problems (a bad email, an unknown plan, zero seats, eleven tags, an extra role key and a 600 character note) produced six errors from jsonschema and six from Pydantic. PostgreSQL CHECK constraints refused four bad rows and kept the good one. Two settings deserve a search of your codebase: additionalProperties: false or extra="forbid", which turn unknown keys into errors, and Pydantic's strict mode, since lax mode converted the string "5" into the integer 5.
On cost, 20,000 validations took 0.39 s in jsonschema, 0.025 s in Pydantic and 0.026 s in fastjsonschema, so the choice matters only on hot paths (validating request JSON before it reaches the database, and for the schema language, writing your first JSON Schema).
Step 5: what you store and what you log
Logs
A structured log line that carries a password is the same bug as an unstructured one, and it lasts longer because it is indexed and shipped. A redactor that matched sensitive key names (password, token, authorization, api_key, card_number, case-insensitive) hid four of seven planted secrets in my test event. The three it missed were a password inside a string holding another JSON document, a token in a URL query string, and a social security number in free text. Value patterns removed the URL token and the number, and parsing any string that looks like JSON removed the third. The pipeline cost about 26 microseconds per event.
Treat that as a net, not a plan. A field called pwd, a secret in an exception message or a card number with spaces in new places would pass any list I wrote. Log fields by name from an allowlist, and do not log request or response bodies by default (hiding secrets from JSON logs).
Output
JSON has a role on the way out, too. Putting JSON inside an HTML page is where a stored value becomes script. In my test, PHP's json_encode turned </script><script>alert(1)</script> into "<\/script><script>alert(1)<\/script>", because it escapes the slash by default, while Python's json.dumps and Node's JSON.stringify left </script> intact. Embedding the latter in a <script> block lets a value end the block and begin another. In PHP, JSON_HEX_TAG | JSON_HEX_AMP turned the angle brackets and ampersand into < and &. In other languages, escape <, > and & yourself (or serve the JSON as a separate response and parse it, which avoids the issue). An API that returns JSON with the right Content-Type and nosniff is a different matter from HTML that contains JSON.
Step 6: pipelines and files, not only requests
The same bugs arrive without an HTTP request. A nightly job that reads a partner's export, a consumer on a queue, a log processor and a migration script parse JSON from a source somebody else controls.
Size. Files can be larger than memory. Use a streaming reader or switch to JSON Lines (handling JSON files too large to parse). The cost is then bounded by one record.
Duplicates and numbers. A partner file read by Python and later by a database loader may disagree on a duplicate key, per the table above.
Poison records. One bad line should not stop a load. Count rejected lines, quarantine them, and fail the job when the rejected fraction passes a threshold.
Provenance. Record where each file came from and its checksum before you parse, so you can say which file produced a value.
For files you only inspect, the tools cost nothing: open them in the JSON formatter or query them with jq or JSONPath (choosing between jq, JSONPath and JMESPath). One caution: paths and queries built from untrusted text are an injection surface too, so pass values as data. A query language that you hand a user's string is a second interpreter.
Prove it with hostile bodies
A checklist is only as good as the evidence that the code follows it. The way I check is to put the whole defense in front of a handler, in the order described above, and send it the bodies that this guide has been about. The handler here is a plain function: size cap, depth cap, strict parse with the two hooks, then a Pydantic model in strict mode with extra="forbid".
def handle(body: bytes):
if len(body) > MAX_BYTES: return 413, "too large"
try: check_depth(body, MAX_DEPTH)
except ValueError as e: return 400, str(e)
try: data = json.loads(body, parse_constant=reject_const, object_pairs_hook=no_dups)
except (ValueError, RecursionError): return 400, "unparseable"
try: Signup.model_validate(data)
except pydantic.ValidationError as e: return 422, error_types(e)
return 200, "ok"The model has four fields (email, plan, seats, note), bounded by a pattern, an enum-like pattern, a range and a length. Fifteen bodies went in:
valid 200 ok
duplicate key 400 duplicate key plan
NaN seats 400 bad constant NaN
1e999 seats 422 int_type
string seats 422 int_type
extra role key 422 extra_forbidden
__proto__ key 422 extra_forbidden
SQL in email 422 string_pattern_mismatch
5000 digit seats 400 Exceeds the limit (4300 digits)
600 char note 422 string_too_long
depth 40 400 too deep
depth 100000 400 too deep
2 MB body 413 too large
not an object 422 model_type
truncated 400 JSONDecodeErrorEvery hostile body got a 4xx, none raised out of the handler, and the valid one passed. Three of the results are worth a second look. The 1e999 body did not fail in the parser. It became inf, and the strict integer field refused a float, so the schema layer caught what the parser let through. The __proto__ body was stopped by extra="forbid" as an ordinary unknown key, which is why the model is the best defense against prototype pollution on the server side: the key never reaches a merge. And the SQL in email body was refused by the email pattern, not by anything that knows about SQL. That is how it should be. A value that fails validation never gets near a query, and the query is still parameterized for the case where validation has a gap.
Keep a file of these bodies next to the endpoint, run it in CI, and add one every time a new kind of bad input turns up in a log or an incident. The point of the exercise is regression: the fifteenth body is the one somebody will break next year when they remove the extra="forbid" line because a client needed one more field.
What this test does not cover
It sends bodies to a function, not to a server. The reverse proxy's size limit, the framework's own parser (which may have run before this function ever saw the bytes) and the way your platform turns an exception into a status code all sit outside it. Test those with a real request: send a body one byte over the proxy's limit and read the status code, send the deep body to the running service and watch its memory, and check the logs for a trace that includes the body. It also says nothing about authorization, which is a different bug class: a perfectly valid body can still be sent by the wrong caller.
A checklist you can paste into a review
Put these in a pull request template for any endpoint or job that parses JSON:
A body size limit is set at the proxy and the framework, and a request one byte over returns 413.
Depth is limited (32 is a reasonable start) before the parser runs.
Duplicate keys,
NaN,Infinityand out-of-range numbers are rejected where the body makes a security decision.Large integers (ids above 2^53) travel as strings.
The parsed object goes through a schema or model with unknown keys forbidden and bounds on every string, array and number.
No code uses a client-supplied key as a column name, a property path or a file name without an allowlist.
Deep merges skip
__proto__,constructorandprototype, or the merge target has no prototype.Queries use bound parameters for every value.
Logs hold fields from an allowlist and do not hold bodies.
JSON inside HTML is escaped for
<,>and&.A test sends each of the hostile bodies from this guide and expects a 4xx with no exception in the log.
The last item is the one that keeps the rest true. Every number in this guide came from running a body through code, and the same bodies make good tests. Keep them in a fixtures folder next to the endpoint.
Auditing an existing service in an afternoon
If you inherit a service and want to know where it stands, an order of work that finds most problems quickly goes like this.
Start by listing every place JSON is parsed. Search for json.loads, JSON.parse, json_decode, ObjectMapper, Unmarshal and the body-parser middleware, and include the places that parse indirectly: a queue consumer, a webhook handler, a function that reads a column of JSON back out of the database. Each hit is an entry point, and the list is usually longer than the team expects.
For each one, ask who controls the text. A browser, a partner, another internal service and a file in your own repository carry different levels of risk, and the answer tells you how much of this guide applies.
Next, send the entry points the hostile bodies. A deep body, a duplicate key, a number with a long exponent and a key called __proto__ take a few minutes to try against a staging copy, and the status codes and memory graphs tell you more than reading the code. An entry point that answers 500 to any of them has an unhandled exception that should be a 400.
Then read the code that touches the parsed object, with two searches. Find every place a key from the body is used as a name: a column in a dynamic query, a property in a merge, a path for a file. And find every place the whole object is logged. These two searches turn up the bugs that matter most, since both sit behind a successful parse and no parser setting can reach them.
Finish by writing down what you found as tests, not a document. A fixture of hostile bodies and a status that each should return will still be there after the next reorganization, and it is much harder to ignore than a page in a wiki.
Further reading
The most useful outside references are the OWASP Cheat Sheet Series (the sections on input validation, SQL injection prevention, logging and prototype pollution prevention), the text of RFC 8259 (read section 4 on objects and section 9 on parsers' freedom to set limits), and the JSON Schema specification. The collection of parsing differences in Nicolas Seriot's the JSONTestSuite behind "Parsing JSON is a Minefield" goes much further than I did, with hundreds of test files run through dozens of parsers.
When this list is more than you need
The data never leaves your machine. A script that reads your own files and writes a report has no adversary. The size and number advice still helps when a file is large, and the rest does not apply.
A framework already does it. A well-configured API framework limits the body, parses strictly and validates against a model. Send it the bodies in this guide before you add your own layers, and add only what the results show is missing.
A trusted internal service calls you. Contract tests and a schema catch drift. Limits still matter as protection against your own bugs, such as a loop that sends a gigabyte.
The cost of a mistake is small. A prototype that logs request bodies while you are the only user is fine. The checklist is for services where someone else's data or money is on the other end.


