SQL Injection and Input Validation: An Audit Checklist
“We use an ORM, so we’re fine”
That sentence, or a version of it, comes up in almost every audit kickoff. And it’s mostly true, which is exactly the problem. Modern ORMs and query builders parameterize by default, so injection has been pushed out of the main roads and into the back alleys: the one raw query someone wrote for performance, the dynamic sort column, the search feature, the analytics endpoint built on a Friday.
An audit doesn’t ask “do you use an ORM?” It asks “where did you leave the ORM?” because that’s where the findings are.
Where injection actually lives in 2026
The raw query escape hatch
Every ORM has one (sequelize.query(), session.execute(), $queryRawUnsafe, cursor.execute() with f-strings), and it exists for good reasons. The vulnerability is interpolating user input into it:
# Vulnerable: f-string builds the SQL
rows = db.execute(
f"SELECT * FROM orders WHERE customer_email = '{email}'"
)
# Fixed: parameterized
rows = db.execute(
text("SELECT * FROM orders WHERE customer_email = :email"),
{"email": email},
)Raw SQL is fine. Raw SQL with string interpolation is a finding, every time, no matter how “internal” the endpoint. The audit method is blunt: grep for every raw-query call site in the codebase and read each one.
Dynamic ORDER BY and column names
The one that slips past even careful teams, because you cannot parameterize an identifier: placeholders work for values, not for column or table names. So this survives code review while user input becomes part of the query structure:
// Vulnerable: sortBy comes straight from the query string
const users = await db.query(
`SELECT * FROM users ORDER BY ${req.query.sortBy}`
);The only correct fix is an allowlist:
const SORTABLE = { name: 'name', created: 'created_at' };
const column = SORTABLE[req.query.sortBy] ?? 'created_at';Same pattern applies to dynamic table names, GROUP BY fields, and the “flexible filtering” endpoints that build WHERE clauses from request JSON.
LIKE clauses and search
Search input concatenated into a LIKE pattern is the usual injection suspect, plus a bonus: unescaped % and _ wildcards let users turn a prefix search into a full-table scan, an availability problem masquerading as a features quirk.
NoSQL operator injection
MongoDB codebases replace quote-escaping problems with operator problems. If the login handler passes the request body into a query, {"password": {"$gt": ""}} matches every non-empty password:
// Vulnerable: body values can be objects carrying operators
const user = await users.findOne({
email: req.body.email,
password: req.body.password,
});The fix is enforcing types before the query: assert both fields are strings (schema validation does this for free), and consider sanitize-mongo-style middleware as a backstop. The same class of bug: $where clauses with interpolated JS, and aggregation pipelines built from request data.
Injection’s cousins
The audit applies the identical lens (does user input become code or structure?) to: shell commands (exec("convert " + filename); use execFile with an argument array instead), path traversal (../../etc/passwd in a filename; resolve and verify the prefix), template injection (user content rendered as a template rather than into one), and XML external entities in legacy parsers.
Validation: the second layer
Parameterization stops injection. Validation stops everything else: the negative quantity, the 40MB “name” field, the role field that shouldn’t be settable at all. Two principles govern how auditors read validation code:
Validate at the trust boundary, not in the UI. Client-side validation is UX. Anything enforced only in the frontend is enforced nowhere; curl doesn’t run your React form logic.
Allowlist, don’t blocklist. Define what valid input is (schema, types, ranges, enums) rather than enumerating known-bad strings. Every blocklist we’ve ever audited had a bypass; usually the auditor finds one in minutes. Related rule: strip-and-block “sanitizers” that delete suspicious substrings tend to be bypassable (<scr<script>ipt>): validation should reject, and escaping should happen at output time.
The strongest pattern is schema validation at the edge (zod, Joi, pydantic, or JSON Schema) so every field is typed, bounded, and stripped of unknown keys before business logic sees it. Unknown-key stripping quietly kills mass assignment ("isAdmin": true in the signup body), which is a top-ten finding in its own right.
The audit checklist
What we actually verify, in roughly the order we verify it:
Query construction
- Every raw-SQL call site located (grep the escape hatches) and read
- No string interpolation/concatenation building any query, anywhere
- Dynamic identifiers (ORDER BY, column/table names) resolved via allowlist maps
- LIKE inputs escaped for
%and_ - NoSQL queries never accept unvalidated objects; types enforced first
- Migration scripts and admin/reporting tools held to the same standard (they touch the same database)
Input validation
- Schema validation at every entry point: HTTP bodies, query params, headers you read, webhook payloads, queue messages
- Unknown fields stripped (mass-assignment protection)
- Length limits on every string; range checks on every number
- Enums/allowlists for anything with finite valid values
- File uploads: type verified by content (magic bytes, not extension or client MIME), size-capped, stored under server-generated names
- Validation failures return 4xx without echoing the offending input into logs or error pages unescaped
Defense in depth
- App’s database user has least privilege (no DDL; if it can’t
DROP TABLE, injection can’t either) - Database errors not returned to clients (schema disclosure)
- Output encoding at render time (the XSS half of the same trust-boundary story)
Teams that want the broader version of this (covering auth, secrets, and dependencies too) can grab our free code audit checklist.
Why this needs eyeballs
SAST tools are genuinely decent at classic SQL injection; taint analysis was built for it. What they miss: the dynamic ORDER BY (looks like a template string, mostly is), NoSQL operator injection (no strings involved), and above all which findings matter: the difference between interpolation in a migration script run by one trusted person and interpolation in a public search endpoint. An auditor reads the query in context, checks what actually feeds it, and hands you five findings that matter instead of a hundred that might. What that report looks like is covered in what to expect in a code audit report.
Get a free code audit
If you want this checklist run against your actual codebase (every raw query read, every entry point checked), that’s what our free audit does, and it’s genuinely free. A Webisoft engineer manually reviews your repository and sends a written findings report with specific file-and-line issues and concrete fixes. Get a free code audit; the grep for escape hatches takes us minutes, and what it turns up is usually worth the read.