SQL Injection Inside the ORM You Trusted
The security scan flagged SQL injection in a service whose developers would have bet their reputation on it being impossible. They used an ORM. Everything was parameterised. The scan was right, and so were they, because the injection lived in the one place parameterisation does not reach: an identifier.
Parameters protect values. They cannot protect table names, column names, sort directions or fragments of SQL, because those are structure, not data, and structure must be part of the query text. Any API that lets user input become structure is an injection, however respectable the wrapper.
This was a Node 22.14 service with a popular ORM over Postgres 16.3, and the defect was a sorting helper.
The hole in one line
const rows = await db.items.findMany({
orderBy: { [req.query.sort]: "asc" },
});
The column name comes from the query string. The ORM builds ORDER BY <something> where something is attacker controlled. There is no parameter for an identifier, so the string is placed into the SQL as structure. A sort parameter of id; DROP TABLE items is too blunt to work against most engines, but a crafted identifier that closes the expression and appends a subquery can exfiltrate data, and that is exactly what the scan demonstrated.
The same hole appears in every ORM's escape hatches: raw fragments, order by, group by, table selection, JSON path arguments, and any "trusted developer string" that a request can influence.
Why parameterisation cannot fix identifiers
A bind parameter is sent to the database separately from the query text and is always treated as a value. It can never become a column name. That is the whole point of it, and also its limit. When you need the column name to vary, you are asking for structure to be dynamic, and the database has no bind type for structure.
So the fix is not a different parameter style. It is to stop letting input choose structure, and instead let input choose from a fixed set of structures you wrote.
The fix: allowlists, everywhere structure varies
For sorting, map the input to a known column:
const SORTS = {
price: { price: "asc" },
newest: { createdAt: "desc" },
name: { name: "asc" },
};
const orderBy = SORTS[req.query.sort] ?? SORTS.newest;
The attacker's string now selects nothing, because it is not a key. Unknown input falls to a default, and the set of possible SQL is the set you wrote and reviewed.
The same pattern applies to every structural choice. Filters that choose a column, reports that choose a grouping, multi tenant code that chooses a table or schema name. Each one becomes a lookup in a fixed map, with a default, and no string from the request ever reaches the query text.
The raw query discipline
Most ORMs provide a raw escape hatch, and it is where the trust dies. The discipline for raw queries is simple to state and hard to follow under deadline: values always as parameters, structure always as literals you wrote.
const rows = await db.$queryRaw`
SELECT * FROM items WHERE category = ${category} ORDER BY price ASC
`;
The template tags parameterise the value. The ORDER BY price ASC is a literal. The moment a variable appears in a structural position, that line is the vulnerability, and the review should treat it as such. Some ORMs offer identifier escaping helpers for the rare genuine need, and using them explicitly, with a comment, is at least a visible decision rather than an interpolated string.
Why scans find this and reviews miss it
The reason this survives review is that the dangerous line looks idiomatic. Dynamic order by is a normal product feature, and the code reads as "sort by what the user asked", which is exactly the requirement. The defect is not in the intent but in the layer the input reaches.
Automated scans find it because they do not read intent. They observe that a request parameter influences query structure and prove it with a payload. This is a good argument for letting the scanner argue with the code, because the scanner is the only reviewer that assumes malice.
The secondary holes in the same family
While fixing the sort helper, audit the family.
Table or schema from input, common in multi tenant sharding, where the tenant id is interpolated into a table name. The tenant id must be validated against the set of real tenants, or mapped, before it touches SQL.
Search and LIKE patterns, where a user string is concatenated into a LIKE clause. The value should be a parameter and the wildcards added by you, with the escape character handled, so the input cannot smuggle structure.
JSON and array operators, where a path into a JSON column is built from input. Paths are structure. Allowlist them like columns.
The rule
Parameterisation protects values, and only values. Every place user input becomes a column, a table, a sort direction, a path or any fragment of query text is an injection, no matter how many layers of ORM stand between the request and the database. Let input select from fixed maps, keep raw queries to literal structure plus parameterised values, and let the scanner prove the negative.
It is also worth testing the positive case, not just the scan. After fixing the sort helper, verify that every legitimate sort value still sorts correctly, because the failure mode of an allowlist fix is quietly routing an unknown but valid product requirement to the default, which ships as "sorting is broken" a week later. The allowlist is a living document that product changes must update, and wiring it to the same place the product defines its sortable fields keeps the two from drifting.
The same "the user supplied the structure" defect appears in open redirects, where input becomes the destination rather than the query, in the login redirect parameter.