An ORM is not a SQL injection prevention tool. It’s a query abstraction layer that happens to parameterize queries in the common case. The moment you reach for a raw query interface — and every major ORM has one — the protection disappears. The developer is back to manually constructing SQL, and the ORM does nothing to help.
This is a real problem because raw queries are common in production codebases. They appear whenever the ORM’s high-level API can’t express a complex query, whenever performance matters at the margin, and whenever a developer is porting legacy SQL into an ORM-based project. The code looks safe because it’s surrounded by ORM code that is safe. The raw section is easy to miss in review.
This guide covers the injection patterns in Django, SQLAlchemy, Sequelize, and Prisma, and what correctly parameterized versions look like in each.
Django ORM
Django’s QuerySet API parameterizes everything it generates. The injection risk comes from three interfaces: raw(), extra(), and direct cursor() use.
raw() — Positional and Named Parameter Bypass
QuerySet.raw() accepts a SQL string and optional parameters. The parameters are bound safely, but developers frequently skip them and use Python string formatting instead:
# Vulnerable — user input interpolated directly into SQL string
user_id = request.GET.get('user_id')
users = User.objects.raw(f"SELECT * FROM auth_user WHERE id = {user_id}")
# Also vulnerable — old-style string formatting
users = User.objects.raw("SELECT * FROM auth_user WHERE id = %s" % user_id)
The safe pattern passes parameters as the second argument to raw():
# Safe — parameters bound separately
user_id = request.GET.get('user_id')
users = User.objects.raw(
"SELECT * FROM auth_user WHERE id = %s",
[user_id]
)
extra() — The Deprecated Injection Surface
QuerySet.extra() is officially deprecated but still present in countless codebases. Its where argument accepts raw SQL clauses, and the params argument is optional — not enforced. Django will not warn you if you pass user input directly:
# Vulnerable — raw SQL in where clause with no parameter binding
search_term = request.GET.get('search')
results = Article.objects.extra(
where=["title LIKE '%" + search_term + "%'"]
)
# Safe — use params argument
results = Article.objects.extra(
where=["title LIKE %s"],
params=[f"%{search_term}%"]
)
Prefer migrating extra() calls to annotate() and filter() with Django’s ORM expressions. If you must keep extra(), always use the params argument.
Direct Cursor — No Protection at All
connection.cursor() gives you a raw database cursor. Django does nothing to protect you here:
from django.db import connection
# Vulnerable
def get_report(request):
report_type = request.GET.get('type')
with connection.cursor() as cursor:
cursor.execute(f"SELECT * FROM reports WHERE type = '{report_type}'")
# Safe
def get_report(request):
report_type = request.GET.get('type')
with connection.cursor() as cursor:
cursor.execute("SELECT * FROM reports WHERE type = %s", [report_type])
The cursor.execute() two-argument form is parameterized at the database driver level. Use it unconditionally.
SQLAlchemy
SQLAlchemy has a 2.x Core API with explicit typed parameters, and a legacy 1.x pattern where text() constructs are common. The injection risk lives in text() with string concatenation.
text() — Correct vs Vulnerable
from sqlalchemy import text
# Vulnerable — f-string inside text()
username = request.form['username']
result = session.execute(
text(f"SELECT * FROM users WHERE username = '{username}'")
)
# Vulnerable — concatenation
result = session.execute(
text("SELECT * FROM users WHERE username = '" + username + "'")
)
# Safe — bound parameters with :param syntax
result = session.execute(
text("SELECT * FROM users WHERE username = :username"),
{"username": username}
)
SQLAlchemy’s :param syntax passes the value to the database driver as a bound parameter. The SQL string itself never contains the user value.
ORM Query Builder — When It Isn’t Enough
SQLAlchemy’s ORM query builder is safe for standard filtering:
# Safe — ORM generates parameterized SQL
user = session.query(User).filter(User.username == username).first()
But filter() accepts text() constructs, which reintroduces the raw SQL surface:
# Vulnerable — text() inside filter()
result = session.query(User).filter(
text(f"username = '{username}'")
).first()
# Safe — use text() with bound parameters even inside filter()
result = session.query(User).filter(
text("username = :username").bindparams(username=username)
).first()
literal_column() and func()
literal_column() inserts a literal SQL fragment — it is never parameterized:
from sqlalchemy import literal_column
# This is always a raw SQL fragment — never pass user input here
col = literal_column(user_input) # vulnerable if user_input is from request
func() generates SQL function calls safely when the function name is a string literal, but becomes dangerous if the function name itself comes from user input:
# Safe — function name is a code constant
result = session.query(func.count(User.id)).scalar()
# Vulnerable — function name from user input
func_name = request.GET.get('aggregation') # attacker can supply "1); DROP TABLE users; --"
result = session.query(func[func_name](User.id)).scalar()
Sequelize (Node.js)
Sequelize’s model methods (.findAll(), .findOne(), .create()) are parameterized. The injection surface is sequelize.query() and the where clause with literal.
sequelize.query() — Replacements vs Concatenation
const { Sequelize } = require('sequelize');
// Vulnerable — string template literal in raw query
const userId = req.query.userId;
const results = await sequelize.query(
`SELECT * FROM Users WHERE id = ${userId}`
);
// Safe — replacements object (positional)
const results = await sequelize.query(
'SELECT * FROM Users WHERE id = ?',
{
replacements: [userId],
type: Sequelize.QueryTypes.SELECT
}
);
// Safe — named replacements
const results = await sequelize.query(
'SELECT * FROM Users WHERE id = :userId',
{
replacements: { userId: userId },
type: Sequelize.QueryTypes.SELECT
}
);
Sequelize.literal() Inside ORM Methods
Sequelize.literal() inserts raw SQL inside a model query. It is designed for cases where the ORM can’t express the needed SQL — but like extra() in Django, it creates an injection surface when user input reaches it:
const sortColumn = req.query.sort; // attacker supplies "1; DROP TABLE Users; --"
// Vulnerable — user input in literal()
const users = await User.findAll({
order: [[Sequelize.literal(sortColumn), 'ASC']]
});
// Safe — allowlist the sort column
const ALLOWED_SORT_COLUMNS = ['createdAt', 'updatedAt', 'username'];
if (!ALLOWED_SORT_COLUMNS.includes(sortColumn)) {
return res.status(400).json({ error: 'Invalid sort column' });
}
const users = await User.findAll({
order: [[sortColumn, 'ASC']] // pass as identifier, not literal
});
Column and table identifiers cannot be bound as parameters — they are SQL identifiers, not values. Always validate them against an explicit allowlist before using in queries.
Prisma (TypeScript/Node.js)
Prisma’s generated client is parameterized end-to-end for all standard operations. The injection surface is $queryRaw and $executeRaw.
$queryRaw — Template Literal vs String Concatenation
Prisma makes the safe pattern the default by leveraging JavaScript tagged template literals:
// Safe — template literal syntax (Prisma parameterizes automatically)
const userId = req.query.userId as string;
const users = await prisma.$queryRaw`
SELECT * FROM "User" WHERE id = ${userId}
`;
// Vulnerable — Prisma.sql with string concatenation bypasses parameterization
const users = await prisma.$queryRaw(
Prisma.sql`SELECT * FROM "User" WHERE id = ` + userId // injection
);
// Also vulnerable — $queryRawUnsafe (the name tells you)
const users = await prisma.$queryRawUnsafe(
`SELECT * FROM "User" WHERE id = ${userId}`
);
The tagged template literal syntax (prisma.$queryRaw\…“) is safe because Prisma intercepts the template and extracts the interpolated values as separate parameters. String concatenation breaks this interception.
$queryRawUnsafe is explicitly documented as bypassing Prisma’s protections. Treat every call to $queryRawUnsafe in a codebase as a code review target — verify that no interpolated value is user-controlled.
Code Review Checklist
When reviewing PRs or auditing a codebase, search for these patterns:
# Django — raw query surfaces
grep -rn "\.raw(" --include="*.py"
grep -rn "\.extra(" --include="*.py"
grep -rn "cursor\.execute" --include="*.py"
# SQLAlchemy — text() with potential concatenation
grep -rn "text(" --include="*.py"
grep -rn "literal_column(" --include="*.py"
# Sequelize — raw query surfaces
grep -rn "sequelize\.query(" --include="*.js" --include="*.ts"
grep -rn "Sequelize\.literal(" --include="*.js" --include="*.ts"
# Prisma — unsafe raw query functions
grep -rn "\$queryRawUnsafe\|rawUnsafe" --include="*.ts"
grep -rn "\$executeRawUnsafe" --include="*.ts"
For each match, verify: is any part of the SQL string derived from user input (request parameters, headers, body fields, path variables)? If yes and there is no parameter binding, it’s a candidate injection.
A Note on Identifier Injection
Column names, table names, and ORDER BY directions cannot be bound as parameters in any database. The database driver always interprets them as SQL identifiers, not values. The safe approach is always an allowlist:
# Python example — allowlist for sort column
SORTABLE_COLUMNS = {'created_at', 'updated_at', 'username', 'email'}
sort_col = request.GET.get('sort', 'created_at')
if sort_col not in SORTABLE_COLUMNS:
sort_col = 'created_at'
# Use sort_col as an identifier — it's now guaranteed safe
Allowlists must be maintained as the schema evolves. Code search for identifier allowlists that have not been updated when columns are renamed or added is a useful finding class in security reviews.