SQL Injection in ORM-Heavy Codebases: Where Raw Queries Sneak Back In
Your team chose an ORM specifically to avoid writing raw SQL. The queries are clean, the models are tidy, and no one is gluing strings together β at least, that's the assumption. In practice, almost every ORM-heavy codebase reintroduces raw SQL in at least a handful of places, and those spots are exactly where attackers look first.
This article walks through the specific patterns that let SQL injection back into a project that's otherwise using an ORM religiously. Each section pairs the vulnerable pattern with a safe replacement you can use today.
What You'll Learn
- Why trusting your ORM completely is a miscalibrated assumption
- The five most common places raw SQL re-enters ORM codebases
- How to spot injectable patterns in Django ORM, SQLAlchemy, and Hibernate
- How to audit your codebase systematically for these patterns
- Concrete fixes for each injection surface
Prerequisites
You should be comfortable reading Python or Java code and have a working understanding of how ORMs map objects to database tables. Familiarity with at least one of Django, SQLAlchemy, or Hibernate will help, but the concepts apply to any ORM. No prior security background is assumed.
The False Sense of Security ORMs Create
ORMs earned their reputation for being safer than raw SQL because they parameterize queries by default. When you write User.objects.filter(email=request.POST['email']) in Django, the ORM sends the email value as a bound parameter, not as part of the SQL string. The database driver keeps the data and the query structure completely separate, which is the correct defense.
The problem is that this protection only holds when you use the ORM's standard query-building interface. The moment you need something the ORM doesn't handle out of the box β a complex aggregation, a database-specific function, a dynamic sort column β the path of least resistance is to drop down to a string. Developers who haven't worked in security often don't realize they've just exited the safe zone.
There's also a subtler issue: ORMs themselves expose raw query interfaces on purpose. They're designed as escape hatches for legitimate use. That makes them a feature, not a bug β but it also means the door to injection is always unlocked; you just have to know not to walk through it carelessly.
The Most Common Places Raw Queries Sneak Back In
Dynamic ORDER BY and Column Name Interpolation
Column names and sort directions cannot be parameterized. A bound parameter substitutes a value into a query; it cannot substitute a column name or the keywords ASC/DESC, because those are structural parts of the SQL statement. This is the single most frequently overlooked injection surface in ORM codebases.
A developer building a sortable table often writes something like this:
# Django β VULNERABLE
sort_col = request.GET.get('sort', 'created_at')
queryset = Article.objects.raw(
f"SELECT * FROM articles ORDER BY {sort_col} DESC"
)
An attacker passes sort=id,(SELECT 1 FROM users WHERE username='admin' AND SLEEP(5))-- and you have a time-based blind injection. The fix is an allowlist:
# Django β SAFE
ALLOWED_SORT_COLS = {'created_at', 'title', 'views'}
sort_col = request.GET.get('sort', 'created_at')
if sort_col not in ALLOWED_SORT_COLS:
sort_col = 'created_at'
queryset = Article.objects.order_by(sort_col) # ORM handles it
If you must build this in raw SQL (for a CASE expression or a database-specific window function), validate against the allowlist first, then interpolate. Never interpolate first and validate later.
Search and Filter Logic Built with String Concatenation
Full-text search and dynamic multi-field filtering are genuinely awkward in most ORMs. The temptation is to build a SQL string conditionally:
# SQLAlchemy β VULNERABLE
query = "SELECT * FROM products WHERE 1=1"
if name:
query += f" AND name LIKE '%{name}%'"
if category:
query += f" AND category = '{category}'"
results = db.execute(query)
SQLAlchemy's text() construct and Django's Q objects exist precisely to handle this pattern safely:
# SQLAlchemy β SAFE
from sqlalchemy import select, or_
from sqlalchemy.orm import Session
stmt = select(Product)
if name:
stmt = stmt.where(Product.name.ilike(f"%{name}%")) # value is bound
if category:
stmt = stmt.where(Product.category == category) # value is bound
results = session.execute(stmt).scalars().all()
The ilike() call looks like it's building a string with the percent signs, but SQLAlchemy passes the entire pattern β including the wildcards β as a single bound parameter. The database never sees the user input as query structure.
Raw Query Escape Hatches in Popular ORMs
Every major ORM ships an explicit raw query interface. These are legitimate features, but they require the same care you'd bring to writing raw SQL from scratch.
Django: Model.objects.raw() and connection.execute(). Both accept a params argument for bound parameters. Using an f-string or % formatting instead of params is the vulnerability.
# Django β VULNERABLE
users = User.objects.raw(f"SELECT * FROM auth_user WHERE username = '{username}'")
# Django β SAFE
users = User.objects.raw(
"SELECT * FROM auth_user WHERE username = %s",
[username]
)
SQLAlchemy: db.execute(text(...)). The text() construct accepts :param-style placeholders. Anything else is a string concatenation risk.
# SQLAlchemy β SAFE
from sqlalchemy import text
result = db.execute(
text("SELECT * FROM orders WHERE status = :status"),
{"status": status_filter}
)
Hibernate (Java): session.createNativeQuery() should always use positional (?1) or named (:param) parameters, never string concatenation.
// Hibernate β VULNERABLE
String hql = "FROM User WHERE username = '" + username + "'";
Query query = session.createQuery(hql);
// Hibernate β SAFE
Query<User> query = session.createQuery(
"FROM User WHERE username = :username", User.class
);
query.setParameter("username", username);
Stored Procedures and Database Function Calls
Stored procedures get called via raw SQL strings in most ORM setups. Teams often treat them as a black box β the procedure is
Frequently Asked Questions
Can an ORM fully protect my application from SQL injection?
No ORM can fully eliminate SQL injection risk on its own. ORMs parameterize standard queries automatically, but they all provide raw query interfaces that bypass those protections. Injection becomes possible the moment user input is interpolated directly into a SQL string, regardless of what framework you use.
How do I safely add dynamic ORDER BY sorting without risking SQL injection?
Use an allowlist of permitted column names and validate the user-supplied sort parameter against it before using it in a query. Column names cannot be bound as parameters, so the allowlist is the only reliable defense for dynamic sorting.
Is using SQLAlchemy's text() function safe?
The text() function itself is not inherently safe or unsafe β it depends on how you use it. When you supply user input via named or positional parameters (e.g., :param syntax), it is safe. When you build the string by concatenating user input before passing it to text(), you have a SQL injection vulnerability.
Where should I look first when auditing an ORM codebase for SQL injection?
Start by grepping for raw query interfaces: raw(), execute(), createNativeQuery(), and text(). Then look for string formatting operators like f-strings, % formatting, or + concatenation anywhere near a query string. Finally, check dynamic ORDER BY, GROUP BY, and column selection logic.
Do database-level stored procedures prevent SQL injection?
Stored procedures reduce the attack surface but do not eliminate injection risk entirely. A procedure that internally builds dynamic SQL using EXEC or sp_executesql with unparameterized inputs is still vulnerable to second-order injection. How the procedure is called from application code also matters.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!