SQL injection¶
When is it relevant¶
A new or changed code path that builds a SQL query from input: request data, files, messages, or data another user stored. This includes raw queries, query-builder raw or whereRaw fragments, sort columns or directions, and ORM methods that accept SQL strings.
Attack¶
The input changes the structure of the query instead of being treated as a value. That lets the attacker bypass filters, read other tables with UNION, change data, or sometimes run commands. Identifiers such as column names and sort order can't be parameterized, so they're often missed.
Mitigation¶
- Pass values as parameters or bindings, never by concatenating strings, including inside raw fragments.
- Allowlist identifiers: sort columns, directions and table names.
- Connect with a least-privilege database user.
Examples¶
Mitigation options
| Option | When it fits | Docs |
|---|---|---|
Parameterised query ($1, $2) |
Every value that comes from a request | node-postgres queries |
| Allowlist for identifiers | Column names, table names, sort direction | — |
Parameters bind values. Table names, column names and sort direction can never be parameterised, so those need an allowlist.
Vulnerable — value interpolated
const { rows } = await db.query(
`SELECT * FROM products WHERE category = '${req.query.category}'`,
)
Vulnerable — parameterised value, interpolated identifier
const { rows } = await db.query(
`SELECT * FROM products WHERE category = $1 ORDER BY ${req.query.sort}`,
[req.query.category],
)
This is the common near-miss: it looks safe because it uses $1.
Mitigated — parameterised query
const { rows } = await db.query(
'SELECT * FROM products WHERE category = $1',
[req.query.category],
)
Docs: node-postgres parameterised queries
Mitigated — allowlist the identifier
const SORTABLE = { name: 'name', price: 'price', newest: 'created_at' }
const DIRECTION = { asc: 'ASC', desc: 'DESC' }
const column = SORTABLE[req.query.sort] ?? 'name'
const direction = DIRECTION[String(req.query.dir).toLowerCase()] ?? 'ASC'
const { rows } = await db.query(
`SELECT * FROM products WHERE category = $1 ORDER BY ${column} ${direction}`,
[req.query.category],
)
The interpolated text comes from the map's values, never from the request, so user input selects a key rather than supplying SQL.
Mitigated — an IN list without string building
const ids = z.array(z.string().uuid()).max(100).parse(req.body.ids)
const { rows } = await db.query(
'SELECT * FROM products WHERE id = ANY($1::uuid[])', // one parameter, whole array
[ids],
)
Bypasses to check
- An
IN (...)list built by joining values, orLIMIT/OFFSETpasted in rather than bound. LIKEpatterns built from input:%and_change the result set, and a leading%can be a denial of service on a large table.- Second order: input stored safely, then read back and concatenated into a later query — a report, a migration, a background job.
- Hand-rolled escaping (
replace(/'/g, "''")) instead of binding. - Check then change: the value is validated, then a different variable (the raw body, or a re-parsed copy) reaches the query.
- Placeholder numbering and the values array drifting apart when the query is assembled conditionally.
client.querycalls inside helpers, migrations and seed scripts, which often skip the conventions the route handlers follow.
What to grep for
db.query(`,client.query(,pool.query(${inside a SQL string,+ req.,.join(',')near a queryORDER BY,LIMIT,OFFSET,TABLEnext to a variable
Mitigation options
| Option | When it fits | Docs |
|---|---|---|
Builder methods (where, whereIn) |
Almost everything, including dynamic filters | Knex query builder |
knex.raw with bindings (?) |
SQL the builder can't express | Knex raw |
| Allowlist for identifiers | orderBy columns, table names |
Knex query builder |
Vulnerable — interpolation inside a raw fragment
Vulnerable — sort column straight from the request
await knex('products')
.where({ category: req.query.category }) // this part is bound
.orderByRaw(req.query.sort) // this part is not
The bound where makes the query look safe; orderByRaw takes raw SQL.
Mitigated — builder methods
await knex('users').where({ email: req.body.email })
await knex('products')
.where({ category: req.query.category })
.whereIn('id', ids) // array bound for you
Mitigated — raw with bindings
await knex.raw('select * from users where email = ?', [req.body.email])
// named bindings, and :name: for an identifier Knex will quote
await knex.raw('select * from :table: where email = :email', {
table: 'users',
email: req.body.email,
})
? and :name bind values; :name: is the identifier form, and Knex quotes it
rather than pasting it in.
Docs: Knex raw bindings
Mitigated — allowlist the sort column
const SORTABLE = { name: 'name', price: 'price', newest: 'created_at' }
await knex('products')
.where({ category: req.query.category })
.orderBy(SORTABLE[req.query.sort] ?? 'name', req.query.dir === 'desc' ? 'desc' : 'asc')
orderBy takes an identifier, not SQL, so a value from the allowlist is safe
where orderByRaw would not be.
Bypasses to check
- Any
*Rawmethod with a template literal:whereRaw,orderByRaw,havingRaw,joinRaw,selectRaw,knex.raw. - A bound
wherenext to an unboundorderByRawin the same chain — the safe half hides the unsafe one. knex.rawused inside a builder call, which reintroduces raw SQL into an otherwise safe chain.- Objects passed to
wherebuilt fromreq.querywholesale, letting the caller choose which columns are filtered. - Check then change: the value is validated, then the raw request value is interpolated into the fragment.
- Migrations and seeds, which commonly use
knex.rawwith string building. orderBygiven a user-supplied string that contains a direction ("price desc"), depending on version behaviour — pass column and direction as separate arguments.
What to grep for
whereRaw,orderByRaw,havingRaw,joinRaw,knex.raw`${inside any of the above.where(req.query,.where(req.body
Mitigation options
| Option | When it fits | Docs |
|---|---|---|
| Prisma Client query API | Almost everything | Prisma CRUD |
$queryRaw tagged template |
SQL the client API can't express | Prisma raw queries |
Prisma.sql + Prisma.join |
Raw SQL assembled from parts | Prisma raw queries |
Vulnerable — the unsafe raw variant
$queryRawUnsafe and $executeRawUnsafe take a plain string: nothing is bound.
Vulnerable — a fragment built by hand, then passed to a tagged template
const filter = `category = '${req.query.category}'`
const rows = await prisma.$queryRaw`SELECT * FROM products WHERE ${Prisma.raw(filter)}`
The tagged template is safe, but Prisma.raw puts the string back in verbatim.
Mitigated — the client API
const products = await prisma.product.findMany({
where: { category: req.query.category, ownerId: req.user.id },
orderBy: { createdAt: 'desc' },
take: 50,
})
Docs: Prisma CRUD
Mitigated — $queryRaw tagged template
const rows = await prisma.$queryRaw`
SELECT * FROM products WHERE category = ${req.query.category}
`
Written as a tagged template, each ${} becomes a bound parameter. The same
text passed to $queryRawUnsafe would not be.
Mitigated — assembled raw SQL
const ids = z.array(z.string().uuid()).max(100).parse(req.body.ids)
const rows = await prisma.$queryRaw(
Prisma.sql`SELECT * FROM products WHERE id IN (${Prisma.join(ids)})`,
)
Prisma.join binds each element, so an IN list needs no string building.
Docs: Prisma raw queries
Mitigated — allowlist a dynamic sort
const SORTABLE = new Set(['name', 'price', 'createdAt'])
const field = SORTABLE.has(req.query.sort) ? req.query.sort : 'name'
const products = await prisma.product.findMany({
orderBy: { [field]: req.query.dir === 'desc' ? 'desc' : 'asc' },
})
Bypasses to check
$queryRawUnsafe/$executeRawUnsafeanywhere — treat every call as a finding until the argument is proven constant.Prisma.raw()inside an otherwise safe$queryRaw, which is the documented escape hatch and the usual near-miss.$queryRawcalled as a function with a built string rather than as a tagged template:prisma.$queryRaw(...${x}...)binds nothing.- A
whereobject spread fromreq.queryorreq.body, letting the caller filter on columns you did not intend, or inject relation filters. orderBykeys taken from input without an allowlist.- Check then change: the value is validated, then the raw request value is interpolated into the SQL.
- Prisma protects against SQL injection, not against missing authorisation —
a safe query with no
ownerId/tenantIdfilter is still a finding.
What to grep for
$queryRawUnsafe,$executeRawUnsafe,Prisma.raw($queryRaw(with a parenthesis rather than a backtickwhere: { ...req.,orderBy: {with a variable key