Volver al blog
14 de agosto de 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

¿Qué tipos de vulnerabilidades detecta Penetrify?

Penetrify detecta todas las categorías de vulnerabilidades del OWASP Top 10, incluyendo inyección SQL, XSS, CSRF, IDOR, autenticación rota, configuraciones de seguridad incorrectas y exposición de datos sensibles. También prueba la seguridad de APIs, la gestión de sesiones y configuraciones incorrectas comunes en Supabase, Firebase y Bubble.

¿Cuánto tiempo dura un test de penetración con IA?

Un escaneo rápido se completa en 15–30 minutos. Un escaneo estándar dura 1–2 horas con mayor cobertura. Un escaneo profundo puede durar varias horas en aplicaciones complejas.

¿Qué incluye un informe de Penetrify?

Cada informe incluye un resumen ejecutivo, una puntuación general de seguridad, hallazgos clasificados por severidad (Crítico, Alto, Medio, Bajo), pasos de reproducción detallados y orientación concreta de remediación escrita para desarrolladores, no para responsables de cumplimiento.

Related articles

¿Qué es SQL Injection? Guía completa de ataques y prevención
Esa angustiosa sensación que te invade al preguntarte si tus consultas a la base de datos son realmente seguras es algo común para muchos desarrolladores. Una simple entrada de usuario sin sanitizar podría ser todo lo que un atacante necesita para desmantelar las defensas de tu aplicación, convirtiendo un simple formulario de inicio de sesión en una brecha de datos catastrófica. Este miedo…
Prevención y pruebas de SQL Injection: El marco de seguridad de 2026
¿Qué pasaría si su conjunto de herramientas de seguridad fuera tan preciso que su ciclo de lanzamiento de 2026 no requiriera ni una sola aprobación manual para garantizar la seguridad? Seguramente ha sentido la frustración cuando el Penetration Testing manual se retrasa 72 horas con respecto a su calendario de implementación, o cuando su herramienta SAST actual marca 40 False Positives por…
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