Security

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.

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.

FAQ

Frequently asked questions

We use an ORM everywhere. Are we actually safe from SQL injection?

Mostly, which is exactly the problem. ORMs parameterize by default, so injection now hides where the ORM was left behind: the raw query someone wrote for performance, the dynamic sort column, the search feature, the reporting endpoint. The useful audit question is not whether you use an ORM but where you left it.

Why can't I just parameterize an ORDER BY column like any other input?

Because placeholders work for values, not identifiers: the database needs to know the query's structure before it binds parameters, and a column name is structure. The only correct fix is an allowlist map from accepted input values to real column names, with a safe default. The same rule covers dynamic table names and GROUP BY fields.

Is MongoDB or other NoSQL immune to injection?

No, it just trades quote-escaping problems for operator problems. If a handler passes request body values straight into a query, an attacker can send an object carrying operators like $gt and match every record. The fix is enforcing types before the query runs, which schema validation gives you for free, with operator-stripping middleware as a backstop.

What is wrong with sanitizing input by stripping dangerous substrings?

Strip-and-block sanitizers tend to be bypassable, often in minutes, and every blocklist we have audited had a hole. Validation should reject input that fails an allowlist definition of what valid looks like (types, ranges, enums, length limits), and escaping should happen at output time. Deleting suspicious substrings does neither job reliably.

Where should input validation live, the frontend or the backend?

Both is fine, but only the server side counts as security. Client-side validation is UX; curl does not run your React form logic, so anything enforced only in the frontend is enforced nowhere. The strongest pattern is schema validation at every server entry point, with unknown keys stripped so mass assignment dies before business logic sees the data.

Free · no strings shown later

Get your free code audit

A repo URL and two minutes of your time. A Webisoft engineer sends back written findings (security, tech debt, performance) with file-and-line specifics.

Request my free audit