“We use an ORM, so SQL injection is handled”
Overview
This page audits the one-line answer to the most repeated question in
application security reviews: how is SQL injection prevented? The answer
“we use an ORM” is so common that it appears in questionnaires as a
checkbox label, and it contains a large, real kernel — an object-relational
mapper does parameterize the queries it generates, binding user values as
typed parameters that the driver transmits out-of-band from SQL syntax.
Within the ORM’s generated-query path, injection is largely solved, which
is a genuine engineering achievement. The claim fails because it converts
that kernel into an unconditional property of the application. An ORM is
not a sandbox; it is a query builder, and every application keeps escape
hatches — raw SQL fragments, native queries, string-built ORDER BY and
column names, second-order reuse of stored strings — where the same string
concatenation the ORM hid returns, minus the visibility that made it
auditable. This page states the claim fairly, marks the exact boundary
where parameterization ends, and works the ORDER BY injection end to end
with request-count arithmetic.
The Claim, Stated Fairly
The strongest common form is: “Our data layer goes through the ORM, so SQL injection is not a threat for us; the framework handles it.” Stated this way, the claim is not a strawman. It is the standard answer in vendor security questionnaires, it is approximately true for the majority of queries in the majority of applications, and it reflects real framework behavior: Django’s queryset API, Rails’ ActiveRecord, Hibernate’s HQL, and Prisma’s query builder all emit parameterized SQL for their value bindings, and drivers deliver those parameters separately from the statement text so they can never be reinterpreted as syntax.
The kernel deserves its fairness because the ORM really did remove the
classic vulnerability. The canonical injection — "SELECT * FROM users WHERE name = '" + input + "'" with an input of ' OR '1'='1 — is
impossible to construct through a value-bound API, because the quote
character in the parameter is data, not syntax. The audit question is
whether “the ORM’s generated queries are parameterized” honestly
compresses to “the application cannot be injected”.
Verdict
Incomplete. The claim is true in the regime where every SQL statement
the application executes is generated by the ORM with all external input
bound as values. It omits the preconditions that decide real outcomes:
identifiers and keywords cannot be parameterized (column names, sort
direction, LIMIT bounds arrive as strings in every API), most codebases
retain raw-query escape hatches for reporting and performance, and
parameterization does not survive second-order reuse — data stored safely
today concatenated into SQL by another component tomorrow. The claim
becomes false whenever it closes the review: an ORM reduces the surface
for injection and leaves the remaining surface less visible than it was
before the ORM arrived.
Why People Believe It
Three forces keep this claim alive. First, the protection is invisible and
total where it operates: the developer writes filter(name=input) and the
parameterization happens three layers down. There is nothing to review,
nothing to forget, and no test that distinguishes bound from concatenated
values on the happy path — so the experience of “the ORM handles it” is
uniform and effortless, exactly like a guarantee.
Second, the vocabulary shifts. Questionnaires ask “are queries parameterized?”, and the honest yes for the ORM’s generated queries reads, by the time it reaches a risk register, as “the application is not injectable”. Each hand-off — developer to questionnaire to register to audit — drops one implicit quantifier (“in the generated-query path”), which is precisely the quantifier the escape hatches live outside.
Third, the escape hatches are respectable. Every mature codebase has
raw(), nativeQuery, sql(), or string-formatted fragments, and each
one had a defensible reason: a window function the ORM predates, a bulk
load, a report, a planner hint. The hatches accumulate one justified pull
request at a time, and the claim — written when the codebase was young —
never gets re-audited against the codebase as it now is.
The Correct Formulation
| Claim component | Correct form |
|---|---|
| “The ORM prevents injection” | The ORM parameterizes the queries it generates; it prevents nothing in code paths that bypass or extend those queries |
| “Values are safe” | Value binding is the core protection and it is real: bound input is data, never syntax |
| “Identifiers are safe” | Column names, sort direction, and table names cannot be bound as parameters anywhere; they require application-side allow-listing, which the ORM does not do for you |
| “Raw queries are rare” | Every mature application has them, with string interpolation risk identical to the pre-ORM world and less review visibility |
| “Stored data is safe” | Parameterization protects the write, not future readers; a stored string concatenated into SQL by a report or script is second-order injection |
| “LIKE queries are safe” | % and _ in bound user input are injection into the pattern, a denial-of-service and data-shaping vector that binding does not address |
The correct sentence: “An ORM parameterizes the SQL it generates, which removes injection from its generated-query path; every place the application leaves that path — raw fragments, dynamic identifiers, second-order reuse of stored strings — returns to the concatenation problem, with the added cost that the code no longer looks like SQL and therefore escapes review that targets SQL.”
Where It Breaks
The ORDER BY injection, worked end to end. A list endpoint takes a
sort parameter, and the code maps it straight through:
Model.order(params[:sort]) in Rails or
.extra(order_by=[request.GET["col"]]) in Django. Sorting keys are
identifiers, and identifiers cannot be parameterized — no driver offers a
placeholder for an ORDER BY expression, which is why the ORM passes the
string through to the statement. An attacker sends
?sort=(SELECT CASE WHEN (SUBSTRING((SELECT current_user),1,1)='r') THEN id ELSE name END).
The response is a 200 in both cases; the row order is the exfiltration
channel. Binary search over the printable characters resolves each
character in about seven requests, so a twelve-character role name
surrenders in roughly 84 requests — all successful, all cacheable, all
indistinguishable from legitimate sorting in query logs. The ORM was in
scope for the whole attack; the claim’s word “handled” never reached the
one parameter the API could not bind.
The escape hatches, audited. Frameworks document the boundary
themselves. Django’s security documentation treats raw() and extra()
as surfaces where the developer owns the SQL string; JPA’s nativeQuery,
Rails’ find_by_sql, and every sql() template in modern builders are
the same door — and the ORM’s own query languages (HQL, JPQL, Cypher) are
string surfaces in their own right, injectable wherever their text is
concatenated rather than parameterized. The honest inventory for a mature
service: full-text
search fragments, migration-time data backfills, cross-database reports,
and performance-critical paths. Each is string-templated in some language,
and the template’s inputs arrive from users more often than their authors
expected — a sort column today, a date-range facet tomorrow. The fix is
not to abandon the ORM; it is to give these paths the review discipline
the claim assumed existed everywhere: allow-list identifiers, bind
everything bindable, and lint for string interpolation into SQL
fragments.
Second-order injection: the boundary parameterization cannot defend.
A profile field is written through the ORM, correctly bound, and stores
xyz'; DROP TABLE audit; -- as inert data — parameterization worked. Eight
months later an analytics script “knows” database values are clean,
concatenates the field into a reporting query, and the stored string
becomes syntax. Every component that wrote the data used the ORM; the
injection happened in a reader the ORM never saw. This is why the
discipline is stated per-query-site, not per-value: every site that
builds SQL must bind, regardless of how trustworthy the data’s provenance
looks, because provenance is exactly what an attacker forges.
What the ORM genuinely cannot parameterize, and the real fixes. The
statement “the framework handles it” should end with the framework’s own
recommendations. Sort columns and directions: an allow-list in application
code ({"name": "name", "created": "created_at DESC"}), never the raw
parameter. Table names for sharded reads: validated against a generated
identifier grammar. LIKE patterns: escape % and _ before binding, or
the facet query that should scan one partition full-scans the table — a
pattern-injection denial of service with bound, “safe” parameters. OWASP’s
injection guidance puts parameterized queries first and immediately adds
the identifier caveat and allow-listing, because the caveats are where the
breaches now live.
The visibility inversion. Before the ORM, SQL lived in a handful of
obvious places and reviewers looked for it. After the ORM, generated
queries are invisible and the remaining string-built SQL is invisible
inside builder calls — .extra(where=["status = '%s'" % status]) reads
like framework API usage, not like the concatenation it is. The claim’s
cost, therefore, is not just the residual surface but the lost ability to
see it: injection review now requires knowing which framework methods
fall through to raw SQL, knowledge that is per-framework, versioned, and
rarely written down.
Practical Implications
- Inventory the raw-SQL surface explicitly: search the codebase for
raw(,nativeQuery,find_by_sql,extra(, andsql(, and treat each hit as a hand-written query under the pre-ORM rules. - Allow-list every identifier that comes from users — sort columns, directions, facet fields, shard suffixes — at the API boundary, and reject anything outside the list rather than escaping it.
- Bind values even when the source is the database: the second-order rule is per-query-site, and “stored data” is attacker-controlled the moment any write path accepts external input.
- Escape
LIKEmetacharacters before binding pattern parameters, or cap pattern-driven scans; pattern injection is a real availability bug that value binding alone does not stop. - Keep the ORM for what it is — parameterized query generation with type mapping — and let security questionnaires say precisely that, with the raw-SQL inventory attached, instead of the blanket “handled”.
- Extend the same audit to the ORM’s query languages: HQL, JPQL, and graph-query strings are injection surfaces of their own whenever user input is concatenated into them rather than bound.
Interview Questions
Where can SQL injection still occur in an application that routes all queries through an ORM?
In identifier positions (sort columns, directions, table and column names), because no driver can bind identifiers as parameters; in raw/native query escape hatches, which most mature codebases accumulate; and in second-order paths where stored strings are later concatenated into SQL by reports or scripts. The ORM’s parameterization covers values in its generated statements — the claim must be scoped to exactly that.
Why can’t the driver just parameterize an ORDER BY clause?
Because parameters bind values, and an ORDER BY clause takes
identifiers and keywords that the planner must resolve to schema objects
at parse time. Binding would turn the clause into a literal string value,
which sorts nothing. The defense is application-side allow-listing of the
permitted sort keys, mapping them to fixed SQL fragments chosen by the
developer — never passing the user string through to the statement.
What is second-order SQL injection, and which layer fails?
Data is stored safely through parameterized writes, then read back and concatenated into a SQL statement by another component that trusted the database’s contents. The failing layer is the second query site, not the first: the rule is that every site that builds SQL binds its inputs, because “stored data” inherits the trust level of the least careful reader, not the most careful writer.
A code review finds .extra(where=["name = '%s'" % name]). The author says it is safe because the ORM escapes it. What is the assessment?
It is string interpolation into SQL executed as-is — the classic concatenation vulnerability wearing an ORM method call. Whether the underlying adapter happens to escape this instance is incidental and unauditable; the pattern is the defect. The fix is the ORM’s parameter binding for the value, and an allow-list if the fragment ever contains an identifier. The review finding stands regardless of exploitability of the current call site.
Key Takeaways
- An ORM parameterizes the queries it generates — a large, real protection that the claim overgeneralizes into “the application cannot be injected”.
- Identifiers and keywords are not parameterizable anywhere; user-chosen sort keys and column names need application-side allow-listing.
- Raw-query escape hatches are universal in mature codebases and carry the full pre-ORM risk with less review visibility.
- Second-order injection defeats provenance trust: every SQL-building site binds, regardless of where the data was written.
- The residual surface is less visible after an ORM adoption, not more — audits must search for the builder methods that fall through to raw SQL.
Cross-References
- Why-Not Handbook: ORM vs Raw SQL — the design decision behind the escape hatches this claim ignores, and when raw SQL is the right call with the right discipline.
- Assumptions Hunt: Input Constraints — the hidden assumptions about what user input can contain, which decide whether the parameterization boundary actually holds.
- Assumptions Hunt: Regex Input Assumptions — the input-trust audit for the validation layer often placed in front of the ORM and assumed to be the real defense.
- Backend Patterns: ORM Internals — how mappers build statements, where parameter binding happens, and which builder calls fall through to string assembly.
- Security: Web Security — the broader injection family (SQL, command, template) and where parameterization fits among the defenses.
- Misconceptions index — the taxonomy used for this verdict.
References
- OWASP. SQL Injection Prevention Cheat Sheet — parameterized queries as the primary defense, with the identifier allow-listing and least-privilege caveats that bound this claim. https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html
- OWASP. OWASP Top 10: A03 Injection — the risk category that consistently ranks at the top of web application risks, including the ORM and second-order cases. https://owasp.org/Top10/A03_2021-Injection/
- Django Software Foundation. Security in Django — the framework’s own statement of what its query API protects and which raw-SQL surfaces return the responsibility to the developer. https://docs.djangoproject.com/en/stable/topics/security/