"We use Prisma, so we cannot have SQL injection." We hear that in scoping calls, and it is wrong in a specific and predictable way. An ORM makes the safe path the default, which is genuinely valuable — and it means the unsafe path, when it appears, appears in isolation, unreviewed, in code nobody expects to contain a security issue.
The result is that injection in modern codebases is rarer and better hidden than it was a decade ago.
Where the Raw Query Comes From
Nobody writes raw SQL for a simple lookup. They write it when the ORM is in the way, and there are four recurring reasons:
Reporting and analytics. Aggregations across joins with window functions are painful to express in an ORM and trivial in SQL. Reporting endpoints are also written late, often by whoever is fastest, and reviewed by people looking at the numbers rather than the query construction.
Performance work. An ORM query is slow, someone rewrites it as SQL with a hand-tuned plan, and the PR is discussed as a latency fix. The interpolated sort column travels in unnoticed because the conversation is about milliseconds.
Dynamic filtering. A list endpoint that accepts arbitrary sort fields, directions and filters is the classic. Parameterised queries cannot bind an identifier — you can bind a value but not a column name or ASC/DESC — so developers build that part of the string by hand, which is exactly where the injection lands.
Bulk operations and migrations. One-off scripts promoted into permanent admin tooling, written when it was "just for us".
// safe hundreds of times
const users = await prisma.user.findMany({ where: { orgId } });
// and then, in the reporting module
const rows = await prisma.$queryRawUnsafe(
`SELECT * FROM orders WHERE org_id = '${orgId}' ORDER BY ${sortBy} ${dir}`
);
Both lines are in the same repository, written by the same team, and only one of them is a vulnerability. Note also that the orgId interpolation makes it a tenant isolation bug at the same time.
Why Your SAST Gate Did Not Catch It
This is the part worth dwelling on, because it changes how teams should think about layering. In our own scan data across 3,847 applications, 91% of the SQL injection findings we confirmed were in codebases that had a static analysis gate in the pipeline. The gate was running. It passed.
Three reasons it misses this class. Interpolation frequently happens across function boundaries — the string is assembled in a helper, the parameter arrives from three frames up, and taint tracking loses it. ORM-specific raw escape hatches ($queryRawUnsafe, sequelize.query, session.execute(text(...))) are not always modelled as sinks by generic rules. And the finding is often suppressed: someone marked it a false positive during a noisy rollout, and the suppression outlived the person who added it.
Meanwhile a dynamic test finds it immediately, because it does not care how the string was built. It sends a payload, watches the response change, and confirms exploitability. That asymmetry — static analysis reads the code and misses it, dynamic testing attacks the endpoint and finds it — is the clearest practical argument for running both, and it is why a green SAST report should not be read as coverage. We laid out the trade-offs in SAST vs DAST vs IAST.
What Exploitation Looks Like
A sort parameter is enough. With ORDER BY injection an attacker can extract data through conditional errors or timing without ever seeing a UNION result, and in Postgres a well-placed subquery in the sort expression is sufficient to read arbitrary tables one value at a time. Automated tooling makes that mechanical rather than clever.
From there the escalation depends on your database user, which in most deployments is far more privileged than the application needs. Read access to every table is the baseline. Write access allows modifying records, including your own permissions if roles live in the database. On some configurations, file read and command execution follow.
The under-appreciated impact is the tenant one: in a multi-tenant application, an injection point bypasses whatever row scoping your ORM was applying, so a single vulnerable report endpoint returns every customer's data even if every other query in the codebase is correctly scoped.
How to Find It in Your Own Codebase
Start with a grep for the escape hatches, because there will be fewer hits than you fear and each one deserves reading:
# Prisma / Sequelize / TypeORM / Knex
grep -rn "queryRawUnsafe\|executeRawUnsafe\|sequelize.query\|createQueryBuilder\|knex.raw" src/
# Python: SQLAlchemy text(), Django raw()/extra()
grep -rn "text(\|\.raw(\|\.extra(\|cursor.execute" .
# any language: string building next to SQL keywords
grep -rniE "(select|insert|update|delete).*(\+|\\$\{|%s|f\")" src/
For each hit, ask one question: does any part of this string come from a request? Not "is it validated" — validation is not parameterisation, and an allow-listed value concatenated into SQL is still concatenated into SQL.
Then test the endpoints dynamically, especially the ones with sorting, filtering and export parameters. A payload as simple as 1,(select 1) in a sort field, or a single quote in a filter, tells you whether the string is being built rather than bound.
The Fix, Including the Part That Is Not Parameterisation
Bind values. Every ORM offers a safe raw variant that parameterises — $queryRaw with a tagged template in Prisma, bind parameters in Sequelize, text() with bound values in SQLAlchemy. Using the unsafe variant should require a comment explaining why, and ideally a lint rule that makes it impossible without one.
For identifiers you cannot bind, allow-list them against a fixed map rather than sanitising:
const SORTABLE = { created: 'created_at', total: 'total_cents' } as const;
const column = SORTABLE[sortBy]; // unknown key -> undefined -> reject
const dir = req.query.dir === 'asc' ? 'ASC' : 'DESC';
if (!column) return res.status(400).end();
Then reduce the blast radius, because injection will eventually happen somewhere: give the application database user only the privileges it needs, keep row-level security enabled so a bypassed ORM scope still meets a database-enforced boundary, and make sure your logs would show an unusual query shape rather than only an application error.
The Broader Pattern
Frameworks that make the secure path default are unambiguously good, and they change where you should look rather than removing the need to look. The vulnerability moves out of the mainstream code — where it would be reviewed constantly — and into the corners: reporting, exports, admin tooling, performance patches, migration scripts promoted to features.
Those corners share a property: they are written by someone in a hurry, reviewed for output rather than construction, and rarely covered by tests. That is a good description of where we find most critical findings generally, not only injection. If you want the aggregate picture from our own scanning, the security report breaks it down by category.
