SQL Injection Isn't Dead: Risks Even With Drizzle and Prisma
ORMs eliminate most SQL injection vectors but don't eliminate all of them. Here's where the risk survives and how to contain it.
The conventional wisdom is that using an ORM means you don't need to worry about SQL injection. This is mostly true and dangerously incomplete. ORMs parameterize the queries they generate — but they also provide escape hatches for raw SQL, and those escape hatches are where injection lives.
Why ORMs Are Mostly Safe
Drizzle, Prisma, and similar ORMs generate parameterized queries. The user-supplied value is passed as a parameter, separate from the query structure. The database driver handles escaping — the value can never change the query's syntax.
// Drizzle — generates: SELECT * FROM users WHERE email = $1
// Value is parameterized, not concatenated
const user = await db.select().from(users)
.where(eq(users.email, userInput));Even if userInput contains ' OR '1'='1, it's treated as a string value, not SQL.
Where the Risk Survives
1. Raw SQL Escape Hatches
Every ORM provides a way to write raw SQL. Drizzle has sql template literal tag. Prisma has $queryRaw and $executeRaw. These are necessary for complex queries the ORM can't express — but they're injection vectors if used carelessly.
// DANGEROUS — userInput is concatenated into SQL
const result = await db.execute(
sql.raw(`SELECT * FROM users WHERE name = '${userInput}'`)
);
// SAFE — using the sql template tag with interpolation
const result = await db.execute(
sql`SELECT * FROM users WHERE name = ${userInput}`
);The sql template literal in Drizzle parameterizes the interpolated values. sql.raw() does not. Read the docs of your specific ORM — the distinction between safe and unsafe raw query APIs is not always obvious.
2. Dynamic Column and Table Names
Parameterization works for values but not for identifiers (table names, column names, order directions). You cannot parameterize ORDER BY column_name — the column name is part of the query structure.
// DANGEROUS — allows arbitrary column names in ORDER BY
const column = req.query.sortBy; // attacker sends: id; DROP TABLE users; --
const result = await db.execute(
sql`SELECT * FROM posts ORDER BY ${sql.raw(column)}`
);
// SAFE — validate against an explicit allowlist
const ALLOWED_SORT_COLUMNS = ['createdAt', 'title', 'viewCount'] as const;
type SortColumn = typeof ALLOWED_SORT_COLUMNS[number];
function validateSortColumn(input: string): SortColumn {
if (!ALLOWED_SORT_COLUMNS.includes(input as SortColumn)) {
throw new Error('Invalid sort column');
}
return input as SortColumn;
}3. JSON and Array Operators
Databases like PostgreSQL expose rich operators for JSON and array types. If you're building dynamic queries against JSONB columns — "find records where the metadata field contains key X" — you're writing SQL fragments dynamically, which reintroduces the injection surface.
4. Second-Order Injection
You store user input (properly parameterized) into the database. Later, a different query reads that stored value and uses it unsafely in a dynamically constructed query. The stored value is trusted because it came from your own database — but it was originally user-supplied.
This is rare with modern ORMs but can appear in admin scripts, reporting queries, or stored procedures that construct dynamic SQL.
Type Safety Is Not a Security Boundary
Drizzle and Prisma provide excellent type safety — the TypeScript compiler enforces that you're passing the right column types. But TypeScript types are erased at runtime. An attacker sending a malicious HTTP request bypasses your type system entirely. Type safety prevents bugs; it doesn't prevent injected strings.
Practical Checklist
- Audit every
sql.raw(),$queryRaw(), and$executeRaw()call in your codebase. Each one needs manual review. - Validate any user input used as a column name, table name, or SQL keyword against an explicit allowlist.
- Use the ORM's parameterized template tag (
sql\...`in Drizzle,Prisma.sql`...`` in Prisma) when you need raw SQL. - Principle of least privilege on the DB user — your app's database credentials should not have
DROP TABLEor DDL permissions.
The vast majority of SQL injection in modern apps comes from raw query escape hatches, not from the ORM's generated queries. Know where yours are.