SQL injection ORM: your ORM doesn't protect raw queries
SQL injection ORM bugs happen when you bypass your ORM, the library that writes database queries for you, and build SQL as a string in a raw query helper. Your ORM parameterizes normal queries, but text you glued together isn't protected. Pass user input as separate parameters, pick sort and column names from a fixed list (an allowlist), and check input with zod, a TypeScript validation library.
QUICK CHECKLIST
- Search the repo for queryRawUnsafe, sql.raw and knex.raw
- Pass every value as a parameter, never in the SQL string
- Map sort and column names from an allowlist in code
- Validate query params and bodies with zod before the database
- Escape % and _ in LIKE searches
- Keep user text out of Supabase .or() strings unless validated
What SQL injection ORM bugs look like
An ORM builds SQL for you. When you call prisma.user.findMany({ where: { email } }), the value reaches the database as a parameter, separate from the query text. The database can't read it as code.
That guarantee ends when the ORM can't express your query. You reach for a raw query helper, build the SQL with a template literal, and the user's text becomes part of the statement. OWASP, a security community whose Top 10 list ranks major web app risks, puts injection at A05 in its Top 10:2025 and names hostile data in ORM search parameters as one way apps become vulnerable.
Normal builder calls (findMany, select().from()) send values as parameters, so they resist classic SQL injection as long as each value has the type you expect. If you pass a parsed JSON body straight into where, an attacker can send an object like { "not": "" } instead of a string and change the filter. Validate types with zod first.
OWASP's SQL injection prevention cheat sheet lists prepared statements with parameterized queries as the first defense. In ORM apps, bugs come from raw helpers fed with string building, from user-chosen column or sort names, and from filter strings the database parses, like Supabase .or() and LIKE.
How attackers use a raw query hole
Say your search endpoint builds SQL from the query string. An attacker adds a single quote to a parameter and watches the response. A database syntax error tells them their input reached the SQL text.
From there they shape the input. OWASP's textbook example is ' OR '1'='1, which rewrites a filter so it matches every row. A UNION SELECT bolts a second query onto yours, so columns from other tables can show up in your JSON response.
ORDER BY is a favorite spot because parameters can't help there. Prisma's docs say template variables work for data values only, not for column names, table names or keywords. A sortable table hits that limit, and the quick way around it is $queryRawUnsafe with a template string.
Raw queries in Prisma, Drizzle, Sequelize and knex
Prisma has four raw methods. $queryRaw and $executeRaw are tagged templates (JavaScript backtick strings that Prisma processes before running them), and Prisma turns each ${value} into a bound parameter. $queryRawUnsafe and $executeRawUnsafe take a plain string, and the docs warn that they put your code at significant risk of SQL injection.
export async function search(q: string, sort: string) {
return prisma.$queryRawUnsafe(
`SELECT id, name FROM "User"
WHERE name ILIKE '%${q}%'
ORDER BY ${sort}`
)
}import { Prisma } from "@prisma/client"
import { z } from "zod"
const ORDER = {
name: Prisma.sql`"name" ASC`,
created: Prisma.sql`"createdAt" DESC`,
} as const
const Input = z.object({
q: z.string().max(100),
sort: z.enum(["name", "created"]),
})
export async function search(input: unknown) {
const { q, sort } = Input.parse(input)
const escaped = q.replace(/[\\%_]/g, "\\$&")
const pattern = `%${escaped}%`
return prisma.$queryRaw`SELECT id, name FROM "User"
WHERE name ILIKE ${pattern}
ORDER BY ${ORDER[sort]}`
}Prisma's docs say a template variable can't sit inside a SQL string literal, so the fixed version builds the whole pattern first and passes it as one value. Prisma.raw() inserts its argument as SQL text, so keep user input away from it too.
Drizzle works the same way. In the sql template, ${id} becomes a placeholder. sql.raw() skips that: the docs say it includes raw SQL "without any additional processing or escaping". Sequelize and knex have the same trap, because a template string passed to sequelize.query() or knex.raw() is string building.
// Unsafe: user text becomes SQL text
sql.raw(`SELECT * FROM users WHERE email = '${email}'`)
// Safe: the value travels separately
sql`SELECT * FROM users WHERE email = ${email}`
sequelize.query("SELECT id FROM users WHERE email = $email", { bind: { email } })
knex.raw("SELECT id FROM users WHERE email = ?", [email])| Safe | Unsafe | |
|---|---|---|
| Prisma | $queryRaw template | $queryRawUnsafe, built string |
| Drizzle | sql with ${value} | sql.raw with user text |
| Sequelize | query() with bind | template string in query() |
| knex | raw() with ? bindings | template string in raw() |
| Supabase | eq(), ilike(), one column | user text inside .or() |
The safe column keeps SQL text fixed and sends values separately.
Dynamic ORDER BY, Supabase .or() filters and LIKE wildcards
For sorting, use an ORDER BY allowlist. The client sends a short key such as created, and your code maps it to a fixed column or SQL fragment. OWASP says table and column names should ideally come from your code, and its Top 10 page notes that SQL structure names can't be escaped.
Supabase has its own version of this problem. The .or() method takes one string of PostgREST filter syntax (PostgREST is the API layer Supabase puts in front of Postgres), where commas separate conditions and dots separate column, operator and value. If you drop user text into that string, the user can add conditions of their own.
That's filter injection. The attacker can't run their own SQL, but they control which rows match. Row Level Security (RLS, the Postgres rules that decide which rows each user can read or change) still applies with the anon key or a user's session, so the damage depends on your policies. A client created with the service_role key bypasses RLS, and then injected filters can reach every row.
export async function searchProfiles(q: string) {
const { data } = await supabase
.from("profiles")
.select("id, name")
.or(`name.ilike.%${q}%,email.ilike.%${q}%`)
return data
}import { z } from "zod"
const Search = z
.string()
.trim()
.min(1)
.max(50)
.regex(/^[a-zA-Z0-9 ]+$/)
export async function searchProfiles(input: unknown) {
const q = Search.parse(input)
const { data } = await supabase
.from("profiles")
.select("id, name")
.or(`name.ilike.%${q}%,email.ilike.%${q}%`)
return data
}LIKE wildcards need a separate fix. A parameter stops SQL injection, but % and _ stay special inside the value, so a search for % matches every row. Escape backslash, % and _ before you add your own wildcards, as the Prisma example does. Backslash is the default LIKE escape in PostgreSQL and MySQL. On SQLite, add ESCAPE '\' to the LIKE clause.
The regex in the Supabase example rejects commas, dots, parentheses and wildcards. If you need to search emails, check the current Supabase docs on quoting filter values, or move the search into a database function that takes parameters.
How to fix raw query holes, step by step
Work through the repo in this order.
Find every raw query
Search for
$queryRawUnsafe,$executeRawUnsafe,Prisma.raw(,sql.raw(,.raw(,.query(and.or(. Trace where each variable comes from.Move values into parameters
Switch to
$queryRawtagged templates, Drizzle'ssqltemplate, Sequelizebindor knex?bindings. The SQL text stays constant.Allowlist anything structural
Sort keys, directions, column names and table names can't be parameters. Map a validated key to a fixed fragment, like the
ORDERobject in the Prisma example.Validate input with zod
Parse query strings, bodies and server action arguments before the database. Use
z.enumfor sort keys andmax()for strings. Server actions take untrusted input too, see Next.js server actions security.Escape LIKE input and limit .or() text
Escape backslash,
%and_in LIKE terms. For Supabase.or(), keep a strict character allowlist or filter one column per call.
Parameters are the main defense. OWASP recommends input validation as a secondary layer even when you use bind variables.
Test it yourself
First, find the candidates:
grep -rnE "queryRawUnsafe|executeRawUnsafe|\.raw\(|\.query\(|\.or\(" app src libThen hit each endpoint that takes a search term or sort key. These requests are harmless on a safe app:
curl -i "http://localhost:3000/api/users?q=%27" curl -i "http://localhost:3000/api/users?sort=not_a_column" curl -i "http://localhost:3000/api/users?q=%25"
A bad result is a 500 with a database message such as an unterminated quoted string or a missing column. That means your input reached the SQL text. A good result is a 400 from validation or a normal 200.
The third request sends a lone %. If it returns every row, your LIKE wildcards aren't escaped.
For Supabase, type zzzz,id.not.is.null,name.ilike.zzzz into your search box. A safe app rejects it or returns nothing. Returned rows or a PostgREST parse error mean your text is being read as filters.
Fix prompt you can paste
Give this to Cursor or Claude Code and review the diff it produces.
Audit this repo for SQL injection through ORMs and query builders.
Search for: $queryRawUnsafe, $executeRawUnsafe, Prisma.raw(, sql.raw(,
sequelize.query( and knex.raw( or whereRaw( or orderByRaw( with template
strings or concatenation, and Supabase .or( or .filter( calls whose
strings contain variables. List each hit as file:line | user input | fix.
Then fix each one. Use $queryRaw tagged templates, the Drizzle sql
template with ${value}, Sequelize bind, and knex ? bindings. For dynamic
ORDER BY, column or table names, add an allowlist object that maps a zod
enum value to a fixed SQL fragment. Validate every query param and body
field with zod. Escape backslash, % and _ in LIKE patterns. For Supabase
.or(), use a strict zod regex or one filter per column. Don't change
anything else. Explain each change in one sentence and list anything you
could not fix safely.In the diff, confirm ORDER BY uses a map of fixed fragments. Regex cleaning of the input is not a fix. For the wider picture, see is AI-generated code secure and the vibe coding security checklist. If you want an outside check of your repo, try Leakcade.
Further reading
- Prisma docs: Raw queries
- Drizzle docs: Magic sql operator
- OWASP SQL Injection Prevention Cheat Sheet
- OWASP Query Parameterization Cheat Sheet
- OWASP Top 10:2025 A05 Injection
FAQ
Does Prisma protect against SQL injection?
Yes for normal queries like findMany and create, where values are sent as parameters. It doesn't protect $queryRawUnsafe or Prisma.raw() when you put user text into the SQL string. Use the $queryRaw tagged template for raw SQL.
Can I parameterize ORDER BY or column names?
No. Parameters only carry data values, and Prisma's docs say template variables don't work for column names, table names or keywords. Map a validated key, such as a zod enum, to a fixed column or SQL fragment you wrote in code.
Is Supabase .or() vulnerable to injection?
It isn't SQL injection, because PostgREST parses the string as filters. But user text inside .or() can add conditions and change which rows match. Validate the text with a strict regex or filter one column per call, and keep Row Level Security on.
Is $queryRawUnsafe always unsafe?
No. The name warns you about string building. If the SQL text is fixed and you pass values as separate arguments with placeholders ($1 on PostgreSQL, ? on MySQL and SQLite), the values are parameters. Prisma's docs still recommend $queryRaw wherever possible.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms