ASVS 5.0 V1.2.4 (Level 1) requires that every database query that includes untrusted data uses parameterized queries, an ORM or entity framework, or another mechanism that keeps data separate from query structure. It covers SQL, HQL, NoSQL and Cypher, and it applies inside stored procedures too.
Why it matters
SQL injection (CWE-89) is an old bug that keeps working because string concatenation is the shortest path to a working query. One concatenated parameter can expose an entire database, or let an attacker log in as anyone.
The cause is always the same: data and code travel in one string, so the parser cannot tell where one ends and the other begins. A parameterized query sends the statement and the values separately, and the database never parses the values as syntax. No escaping function can match that guarantee, because escaping depends on getting every character and encoding right in every case.
How to implement it
Use placeholders for every value, in every language. Here are four common stacks.
Python with psycopg:
cur.execute(
"SELECT id, email FROM users WHERE email = %s AND active = %s",
(email, True),
)
Node with pg:
const { rows } = await pool.query(
'SELECT id, email FROM users WHERE email = $1 AND active = $2',
[email, true]
);
Java with JDBC:
try (PreparedStatement ps = conn.prepareStatement(
"SELECT id, email FROM users WHERE email = ? AND active = ?")) {
ps.setString(1, email);
ps.setBoolean(2, true);
try (ResultSet rs = ps.executeQuery()) { /* read rows */ }
}
C# with Dapper:
var user = await conn.QuerySingleOrDefaultAsync<User>(
"SELECT Id, Email FROM Users WHERE Email = @email",
new { email });
The parts placeholders cannot cover
Table names, column names and sort directions cannot be parameters. Map user choices to a fixed allowlist instead.
SORTABLE = {"created": "created_at", "name": "display_name"}
col = SORTABLE.get(sort_key, "created_at")
direction = "DESC" if desc else "ASC"
query = f"SELECT id, display_name FROM users ORDER BY {col} {direction}"
Here the f string contains only values chosen from your own dictionary, never user text.
For IN lists, generate the placeholders, not the values:
placeholders = ",".join(["%s"] * len(ids))
cur.execute(f"SELECT * FROM orders WHERE id IN ({placeholders})", ids)
NoSQL and graph databases
MongoDB injection happens when a request body object is passed straight into a filter, so {"password": {"$ne": ""}} matches everyone. Validate types before querying.
if (typeof req.body.email !== 'string' || typeof req.body.password !== 'string') {
return res.status(400).end();
}
const user = await users.findOne({ email: req.body.email });
For Cypher, use query parameters (MATCH (u:User) WHERE u.email = $email) and pass a separate parameter map.
Stored procedures
A procedure that builds dynamic SQL with string concatenation is just as injectable as application code. Inside it, use sp_executesql with parameters on SQL Server or EXECUTE ... USING in PostgreSQL.
How to test it
- Static review: search for queries built with
+, f strings,format,String.Formator template literals that include variables, and check each one. - Manual probes: submit
',",' OR '1'='1and1; SELECT pg_sleep(5)in every field that reaches a query. Look for errors, changed results or a delay. - Type probes for document databases: send JSON objects where strings are expected.
- Run an automated scan against a staging environment. A static scan finds concatenation in code and a dynamic scan confirms it from outside.
A concrete pass criterion: a search for ' OR '1'='1 returns zero rows, not all rows, and the application logs show the literal value passed as a parameter.
Click through it at your own pace, or open it full screen.
Common mistakes
- Using an ORM and then dropping to a raw query method with concatenation for "one tricky report".
- Escaping quotes by hand and calling it done.
- Parameterizing the
WHEREclause but concatenating theORDER BY. - Trusting numeric looking values. Cast them to integers in code, or bind them as integers.
- Assuming a WAF is a substitute.
- Forgetting second order injection, where a stored value is later concatenated into a query by a batch job.
Try it yourself
Pick one repository and run these searches. They take ten minutes and often find real problems.
grep -rnE "(execute|query|raw|prepare)\(.*(\+|f\"|\$\{|format\()" src/
grep -rnE "String\.format\(.*(SELECT|INSERT|UPDATE|DELETE)" src/
For each hit, ask two questions. Does any part of the concatenated text come from outside the process, including a queue message, a file or a value read back from the database? If yes, rewrite it with placeholders. If the text is built from constants only, add a comment that says so, since the next reader will have the same worry.
Then write one integration test per data access function that passes a quote character and a SQL comment marker as the value and asserts that the function returns no rows and raises no error. A test like this protects against a future refactor that swaps a safe call for a concatenated one.
Quick check
Can you parameterize a table name?
No. Placeholders only carry values. Use an allowlist that maps user input to fixed identifiers you control.
Is an ORM automatically safe?
Only while you use its query builder. Raw query escape hatches that concatenate input are as injectable as hand written SQL.