ORMs Reduce SQL Injection Risk — They Don’t Eliminate It
Most developers assume that using an ORM makes SQL injection a non-issue. That’s true for the ORM’s standard query builder methods, but every major ORM has escape hatches for raw SQL, and those escape hatches carry exactly the same risk as hand-written queries.
Sequelize (Node.js)
// Vulnerable: string interpolation into a literal replacement
const results = await sequelize.query(
`SELECT * FROM users WHERE email = '${email}'`
);
// Safe: named replacements
const results = await sequelize.query(
'SELECT * FROM users WHERE email = :email',
{ replacements: { email }, type: QueryTypes.SELECT }
);
Eloquent (Laravel)
// Vulnerable: whereRaw with concatenated input
User::whereRaw("email = '" . $email . "'")->get();
// Safe: parameter binding, even inside whereRaw
User::whereRaw('email = ?', [$email])->get();
// Safest: use the query builder directly
User::where('email', $email)->get();
Django ORM (Python)
# Vulnerable: raw() with f-string interpolation
User.objects.raw(f"SELECT * FROM auth_user WHERE email = '{email}'")
# Safe: parameterized raw query
User.objects.raw("SELECT * FROM auth_user WHERE email = %s", [email])
Dynamic Sorting and Filtering: A Sneakier Trap
Injection doesn’t only happen in WHERE clauses. Dynamic ORDER BY or column names built from user input are a common oversight because they don’t look like typical “user data” injection points:
// Vulnerable: column name built directly from query param
const results = await sequelize.query(
`SELECT * FROM products ORDER BY ${req.query.sortBy}`
);
// Safe: validate against an allowlist before use
const allowedColumns = ['price', 'name', 'created_at'];
const sortBy = allowedColumns.includes(req.query.sortBy) ? req.query.sortBy : 'created_at';
const results = await sequelize.query(`SELECT * FROM products ORDER BY ${sortBy}`);
Search Filters with Dynamic Query Building
Building queries programmatically based on filter objects is another common source of raw-query fallback:
// Vulnerable: filter keys used directly as column names
foreach ($filters as $column => $value) {
$query->whereRaw("$column = ?", [$value]);
}
// Safe: whitelist permitted filter columns first
$allowed = ['status', 'category_id', 'user_id'];
foreach ($filters as $column => $value) {
if (in_array($column, $allowed, true)) {
$query->where($column, $value);
}
}
Auditing Your Codebase
Search your codebase for raw query methods specifically — whereRaw, query(), raw(), executeSql — and review each one individually. These are a small, findable surface area compared to auditing every query in the application.
Conclusion
ORMs push SQL injection risk into a smaller, more auditable surface — raw query and dynamic identifier code paths — rather than eliminating it entirely. Treat every raw SQL escape hatch and every dynamically built column/table name as a security review checkpoint, not just user-supplied values in a WHERE clause.