ORM Injection
Every ORM has an escape hatch to raw SQL, and every one of them is a footgun. Where the abstraction stops protecting you, and which method names to grep for.
The False Sense of Safety
"We use an ORM" is treated as an answer to "are you safe from SQL injection". It is not.
ORMs parameterise their generated queries, which covers the ordinary path well. But every ORM provides a raw-SQL escape hatch, because real applications eventually need something the query builder cannot express. Those escape hatches take strings — and strings get built with interpolation.
Worse, the failure is concentrated: a codebase that is 99% safe ORM calls will have a handful of raw fragments, and those are where the whole risk lives. They are also easy to miss in review precisely because the surrounding code is so consistently safe.
The fastest audit of any ORM codebase is to grep for the raw-SQL methods and check each one.
What to Grep For
| Framework | Dangerous with interpolation | Safe equivalent |
|---|---|---|
| Django | .extra() .raw() RawSQL() | Q objects, .raw(sql, params) |
| SQLAlchemy | text() with f-string, .filter(text(…)) | text(…).bindparams() :name placeholders |
| ActiveRecord | where("x = #{v}") find_by_sql order(params[:s]) | where("x = ?", v) where(x: v) |
| Sequelize | sequelize.literal() sequelize.query(str) | replacements / bind options |
| Prisma | $queryRawUnsafe $executeRawUnsafe | $queryRaw tagged template |
| TypeORM | .where(`x = ${v}`) .query(str) | .where('x = :v', { v }) |
| Knex | knex.raw(`… ${v}`) whereRaw(str) | knex.raw('… ?', [v]) |
| Hibernate / JPA | createQuery("… " + v) | setParameter() Criteria API |
| MyBatis | ${value} | #{value} |
| GORM (Go) | db.Raw(fmt.Sprintf(…)) .Where(str) | db.Raw(sql, args…) .Where("x = ?", v) |
| Eloquent (Laravel) | DB::raw() whereRaw("x = $v") | whereRaw('x = ?', [v]) where('x', v) |
The MyBatis Trap
MyBatis deserves special mention because its two placeholder syntaxes differ by a single character and behave completely differently:
#{value}becomes a bound parameter. Safe.${value}is string substitution into the SQL text. Injectable.
They look nearly identical in a code review, especially in XML mapper files that reviewers skim. ${} exists because identifiers cannot be parameterised — so it has legitimate uses in ORDER BY and dynamic table names — but every occurrence needs an allowlist behind it.
Grep every mapper for ${ and justify each hit individually.
MyBatis: one character apart
<!-- ${} substitutes the raw string into the SQL text. -->
<select id="findByName" resultType="User">
SELECT * FROM users WHERE name = '${name}'
</select>
<!-- Also injectable, and the more common real-world case: -->
<select id="list" resultType="User">
SELECT * FROM users ORDER BY ${sortColumn} ${sortDir}
</select>ActiveRecord: Interpolation in where()
# String interpolation inside where() is injectable.
User.where("name = '#{params[:name]}'")
# Order is an identifier context — no placeholder available,
# so this is a very common real-world hole.
Product.order(params[:sort])
# find_by_sql takes a raw string
User.find_by_sql("SELECT * FROM users WHERE id = #{params[:id]}")
# Even a seemingly harmless helper:
User.where("created_at > '#{params[:since]}'")Prisma: Tagged Template vs Unsafe
// $queryRawUnsafe takes a plain string. The name says so.
const users = await prisma.$queryRawUnsafe(
`SELECT * FROM users WHERE email = '${email}'`
)
// Subtler: building a string and passing it to the tagged
// template defeats the protection entirely, because the
// template sees ONE interpolation containing the whole query.
const sql = `SELECT * FROM users WHERE email = '${email}'`
const users2 = await prisma.$queryRaw(Prisma.raw(sql))Django and SQLAlchemy
# .extra() is deprecated precisely because of this.
User.objects.extra(where=[f"name = '{name}'"])
# .raw() with an f-string
User.objects.raw(f"SELECT * FROM users WHERE name = '{name}'")
# RawSQL inside annotate/filter
from django.db.models.expressions import RawSQL
User.objects.annotate(x=RawSQL(f"SELECT {col} FROM t", []))
# order_by with user input — Django validates field names here,
# so this raises rather than injects, but .extra(order_by=...) does not.Finding These in Practice
White box. Grep the table above. In most codebases there are fewer than fifty hits, and reviewing each one by hand is an afternoon's work with a very high yield.
# Ruby
grep -rn 'where("' --include='*.rb' | grep '#{'
# JavaScript / TypeScript
grep -rn 'queryRawUnsafe\|\$executeRawUnsafe\|sequelize.literal\|whereRaw\|knex.raw' --include='*.ts' --include='*.js'
# Python
grep -rn '\.extra(\|\.raw(\|RawSQL(\|text(f"\|text(f'\''' --include='*.py'
# Java
grep -rn 'createQuery("\|createNativeQuery("' --include='*.java' | grep '+'
# MyBatis
grep -rn '\${' --include='*.xml'
Black box. ORM-generated queries have recognisable shapes — aliases like t0, _1, fully-qualified column lists. Error messages often leak the generated SQL along with the ORM's own stack frames, which tells you which framework you are against and therefore which escape hatches to probe. Concentrate on sort, filter, and search parameters: those are the features that most often outgrow the query builder.
Prevention
- Prefer the query builder. If the ORM can express it, use the ORM. Raw SQL should be rare enough that each instance is noticed.
- When raw SQL is necessary, always pass parameters separately. Every framework in the table supports it; none of them make it the default-looking option.
- Allowlist identifiers. Sort columns, table names, and directions cannot be parameterised in any framework. Map user input to a fixed set.
- Ban the unsafe methods in CI. A lint rule or a grep in the pipeline that fails on
$queryRawUnsafe,${in mappers, or interpolation insidewhere("…")costs nothing and catches regressions. - Review raw fragments specifically. Make "does this file contain raw SQL" a checklist item rather than trusting a general read-through.
Related
Why string-concatenated queries break: the database never sees your intent, only a finished string it must parse as code.
Where your input lands in the query determines which payloads can possibly work. Get the context wrong and every payload fails for the wrong reason.
Input that is stored safely and then used unsafely somewhere else entirely. The payload and the vulnerable query live in different requests, different code paths, and often different applications.
Filter, sort and search parameters in REST and GraphQL are built for flexibility — which usually means built by concatenation. Where modern APIs reintroduce a solved problem.
PreparedStatement, the MyBatis #{} vs ${} distinction, JPA bind parameters, and the identifier problem no placeholder solves.
Placeholders in pg and mysql2, Knex raw bindings, and Prisma's tagged-template-vs-Unsafe distinction.