Back to Blog
August 14, 2026

SQL Injection Despite the ORM: The One Raw Query Nobody Reviewed

Viktor Bulanek
Founder & CTO, Penetrify
MSc IT Security · 20+ years in security · 4x Ex-CTO

"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.

Frequently Asked Questions

What types of vulnerabilities does Penetrify detect?

Penetrify detects all OWASP Top 10 vulnerability categories including SQL injection, XSS, CSRF, IDOR, broken authentication, security misconfigurations, and sensitive data exposure. It also tests API security, session management, and common misconfigurations in Supabase, Firebase, and Bubble.

How long does an AI penetration test take?

A quick scan completes in 15–30 minutes. A standard scan runs 1–2 hours with broader coverage. A deep scan can run several hours for complex applications.

What does a Penetrify report include?

Every report includes an executive summary, overall security score, severity-classified findings (Critical, High, Medium, Low), step-by-step reproduction steps, and concrete remediation guidance written for developers — not compliance officers.

Related articles

What Is SQL Injection? A Complete Guide to Attacks & Prevention
That gut-wrenching feeling when you wonder if your database queries are truly secure is a familiar one for many developers. A single, unsanitized user input could be all an attacker needs to unravel your application's defenses, turning a simple login form into a catastrophic data breach. This fear o…
SQL Injection Prevention and Testing: The 2026 Security Framework
What if your security suite was so precise that your 2026 release cycle didn't require a single manual sign-off to guarantee safety? You've likely felt the frustration when manual pentesting lags behind your deployment schedule by 72 hours, or when your current SAST tool flags 40 false positives for…
What an autonomous pentest agent found in 3,847 apps — and what your scanner didn't
A data breakdown of 47,291 exploitation-validated findings, with methodology and limitations. 91% of the SQL injection we found shipped despite a SAST gate in CI; 78% of critical findings needed no login.

Explore more