Torna al Blog
14 agosto 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

Quali tipi di vulnerabilità rileva Penetrify?

Penetrify rileva tutte le categorie di vulnerabilità OWASP Top 10, inclusi SQL injection, XSS, CSRF, IDOR, autenticazione compromessa, configurazioni di sicurezza errate ed esposizione di dati sensibili. Testa anche la sicurezza delle API, la gestione delle sessioni e le comuni configurazioni errate in Supabase, Firebase e Bubble.

Quanto dura un test di penetrazione con IA?

Una scansione rapida si completa in 15–30 minuti. Una scansione standard dura 1–2 ore con una copertura più ampia. Una scansione approfondita può durare diverse ore per applicazioni complesse.

Cosa include un report di Penetrify?

Ogni report include un sommario esecutivo, un punteggio di sicurezza complessivo, i risultati classificati per gravità (Critico, Alto, Medio, Basso), procedure di riproduzione dettagliate e indicazioni concrete di rimediazione scritte per gli sviluppatori, non per i responsabili della conformità.

Related articles

Cos'è l'SQL Injection? Guida Completa ad Attacchi & Prevenzione
Quel nodo allo stomaco che si prova quando ci si chiede se le query al database siano realmente sicure è una sensazione fin troppo comune per molti sviluppatori. Un singolo input utente non validato potrebbe essere tutto ciò di cui un attaccante ha bisogno per smantellare le difese della tua applicazione, trasformando un semplice modulo di login in una catastrofica violazione dei dati. Questa paura…
Prevenzione e test di SQL Injection: il framework di sicurezza del 2026
E se la tua suite di sicurezza fosse così precisa da non richiedere una singola approvazione manuale per garantire la sicurezza del tuo ciclo di rilascio del 2026? Probabilmente hai provato la frustrazione quando il Penetration Testing manuale è in ritardo di 72 ore rispetto alla tua pianificazione di rilascio, o quando il tuo attuale strumento SAST segnala 40 False Positives per...
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