SQL injection in ORM apps: where it still gets in
Code review finds "SELECT ... " + user_input quickly. In applications built on an ORM, the injections that get through are quieter and look like normal code.
What parameterisation covers
A query with placeholders, WHERE email = %s with the value passed separately, sends the value to the database as data. It cannot change the structure of the statement. Modern ORMs do this for filter values automatically.
It covers values only. A placeholder cannot stand in for a table name, a column name or a keyword. That gap is where most modern injections live.
Places to search for
- Raw escape hatches. In Django:
.raw(),.extra(),RawSQL, andcursor.execute()with an f-string or%formatting. In other frameworks, the equivalents. Search for them and read each one. The safe form iscursor.execute(sql, params). - User-controlled column names. A sort parameter like
?sort=pricebecomesORDER BY price. If the value goes into a raw query,?sort=price; DROP ...or a subquery that extracts data one bit at a time is possible. The ORM's ownorder_by()validates field names and raises an error for unknown ones, but a raw query does not. Map user input through an allowlist:{'price': 'price', 'newest': 'created_at'}. - Identifiers built dynamically. Table-per-tenant designs and reporting features often build identifiers from data. Use the driver's identifier quoting (
psycopg.sql.Identifierin psycopg) rather than string formatting. LIKEwildcards. Passing%or_inside aLIKEpattern is not code injection, but a user can craft patterns that force expensive scans. ORMs'containslookups escape wildcards; hand-writtenLIKE '%' || %s || '%'does not.- JSON and full-text operators. Path expressions and search query syntax (
to_tsqueryaccepts operators and can throw errors on malformed input) are their own small languages. Use the safer variants, such aswebsearch_to_tsqueryin PostgreSQL, and validate paths. - Second-order injection. A value stored safely today is read back later and concatenated into a query by a different job. Treat data from your own database as untrusted when it goes into SQL text.

Reduce the damage
Assume some injection exists and limit what it can do. Give the application a database user with only the privileges it needs, no SUPERUSER, no FILE or COPY ... PROGRAM rights, and separate read-only credentials for reporting code. Errors that echo the SQL to the browser make blind injection much easier, so keep debug output off in production.
Static analysis (Semgrep and Bandit have rules for raw SQL formatting) finds most of the code patterns cheaply. Add it to CI, and treat a new use of a raw query as something a reviewer must sign off.
The NoSQL version of the same mistake
Document databases have their own version of this problem, and "we use an ODM, not raw SQL" does not rule it out. MongoDB queries are built from data structures, not strings, so there is no quote character to escape, but operators such as $ne, $gt and $regex are themselves just keys in that data structure. A login handler that builds a filter from parsed request JSON without restricting its shape, for example {"username": user_input, "password": password_input} where password_input arrives as the object {"$ne": null} instead of a string, can end up asking "does a user exist whose password is not null", which is true for almost every account. The fix mirrors the SQL case: validate the type and shape of every field pulled from user input before it becomes part of a query, and do not pass a parsed JSON body into a query builder unfiltered on the assumption that "it is just data, not a string" makes it safe.