Audit BenchAi
← All posts

How to Review Code for SQL Injection Risk

·8 min read

SQL injection has been a known, well-documented vulnerability for more than two decades, and it still shows up in modern codebases. Not because engineers do not know it exists, but because it hides in places that do not look like the classic textbook example. Nobody writes "SELECT * FROM users WHERE id = " + userInput on purpose anymore. Injection risk today lives in ORM escape hatches, dynamic sorting logic, report builders, and code that was written quickly to support a feature, not to withstand hostile input. Reviewing for it means knowing where those places are, not just recognizing the obvious pattern.

Where injection hides in modern codebases

  • Raw query strings built with string concatenation, f-strings, template literals, or the%/.format() operators, especially in one-off admin scripts or internal tools.
  • ORM "escape hatches" — .raw(), .execute(), or a native query builder call used because the ORM's normal query API could not express what the developer needed.
  • Dynamic sorting and filtering, where a column name or sort direction comes from a query parameter and is inserted directly into an ORDER BY or WHERE clause.
  • Dynamic table or schema names, common in multi-tenant systems that shard data by client, where the table name itself is built from user-controlled input.
  • Stored procedures and views that concatenate parameters internally, which reviewers often skip because the vulnerable code lives outside the application repository.

The one question that catches most injection bugs

For every query touched in a diff, a reviewer should be able to answer one question clearly: does any part of this query string get built by combining a fixed template with a value that ultimately came from a user — a request body, a query parameter, a header, or even a value pulled from the database that originated as user input earlier. If the answer is yes, the very next question is whether that value passes through a parameterized placeholder or gets inserted as literal text into the query. If it is inserted as text, that is an injection risk regardless of how unlikely the specific input seems today.

ORMs reduce risk but do not eliminate it

Teams that use an ORM often assume injection is solved by default, and for standard query construction that is largely true — most ORMs parameterize values automatically when you use their normal query-building API. The risk reappears in exactly the places where the ORM's abstraction runs out: raw SQL fragments passed to a .raw() or .literal() helper, dynamic column or table names (which most ORMs cannot parameterize the same way as values, because identifiers are not values), and complex filtering logic assembled from multiple optional conditions where a developer drops down to string building to keep the code simple.

Second-order injection is easy to miss

Not every injection risk is obvious at the point where user input enters the system. Second-order injection happens when a value is stored safely — as plain data, with no immediate query risk — and is later read back and used to build a different query without the same care. A username or display name stored safely on signup can become an injection vector months later if a reporting job builds a query by concatenating it into a filter. Reviewers should trace where stored values are reused in query construction, not only where they first enter the system.

Checklist before approving a PR that touches queries

  1. Every value that varies per request is passed through a parameter placeholder, never concatenated or interpolated into the query string.
  2. Any raw SQL fragment has been checked specifically for concatenated user input, not just skimmed.
  3. Dynamic column, table, or sort-field names are validated against an explicit allow list, not passed through directly.
  4. Stored procedures or views involved in the change have been reviewed too, not assumed safe because they live outside the main codebase.
  5. Values that were previously stored as plain data and are now being reused in a new query have been checked for second-order risk.

Input validation is not a substitute for parameterization

Validating that an input looks like a reasonable username or a plausible number is good practice, but it is not the control that stops injection. Validation reduces the shape of bad input; parameterization removes the ability for input to change the structure of the query at all. A reviewer who sees strong validation but string-concatenated queries should still flag the query construction, because a sufficiently motivated attacker will find input that passes the validation and still breaks the query.

Read the OWASP Top 10:2025, explained →

See how to review API changes for security risk →