Zurück zum Blog
14. August 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

Welche Arten von Sicherheitslücken erkennt Penetrify?

Penetrify erkennt alle OWASP-Top-10-Schwachstellenkategorien, darunter SQL-Injection, XSS, CSRF, IDOR, fehlerhafte Authentifizierung, Sicherheitsfehlkonfigurationen und die Offenlegung sensibler Daten. Es testet auch die API-Sicherheit, das Session-Management und häufige Fehlkonfigurationen in Supabase, Firebase und Bubble.

Wie lange dauert ein KI-Penetrationstest?

Ein Quick-Scan ist in 15–30 Minuten abgeschlossen. Ein Standard-Scan läuft 1–2 Stunden mit breiterer Abdeckung. Ein Deep-Scan kann für komplexe Anwendungen mehrere Stunden dauern.

Was enthält ein Penetrify-Bericht?

Jeder Bericht enthält eine Executive Summary, einen Gesamtsicherheitsscore, nach Schweregrad klassifizierte Befunde (Kritisch, Hoch, Mittel, Niedrig), schrittweise Reproduktionsschritte und konkrete Abhilfemaßnahmen – geschrieben für Entwickler, nicht für Compliance-Beauftragte.

Related articles

SQL Injection: Der umfassende Leitfaden zu Angriffen & Prävention
Dieses nagende Gefühl, wenn man sich fragt, ob die eigenen Datenbankabfragen wirklich sicher sind, kennen viele Entwickler nur allzu gut. Eine einzige, ungeprüfte Benutzereingabe kann für einen Angreifer ausreichen, um die Abwehrmechanismen Ihrer Anwendung auszuhebeln und ein simples Login-Formular in eine verheerende Datenpanne zu verwandeln. Diese Angst…
SQL Injection Prävention und Testing: Das Sicherheitsframework 2026
Was wäre, wenn Ihre Sicherheitslösung so präzise wäre, dass Ihr Release-Zyklus 2026 keine einzige manuelle Freigabe benötigen würde, um die Sicherheit zu gewährleisten? Sie haben wahrscheinlich schon die Frustration erlebt, wenn manuelles Penetration Testing Ihren Deployment-Zeitplan um 72 Stunden verzögert oder wenn Ihr aktuelles SAST-Tool 40 False Positives meldet für…
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