The only rule you need
Never concatenate user-controlled values into SQL strings. Ever. Without exception. Use parameterized queries or a query builder that handles escaping for you.
// ✗ Vulnerable — user can escape the query
const rows = await db.query(
`SELECT * FROM users WHERE email = '${req.body.email}'`
);
// ✓ Safe — value is passed separately, never interpolated
const rows = await db.query(
'SELECT * FROM users WHERE email = $1',
[req.body.email]
);ORMs protect you by default
Prisma, Drizzle, and TypeORM all use parameterized queries under the hood. When you write prisma.user.findFirst({ where: { email } }), the email value is never concatenated into the SQL string.
The risk with ORMs: raw query escape hatches.
// ✗ Vulnerable — raw query with interpolation
await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE id = '${id}'`);
// ✓ Safe — tagged template literal or parameterized
await prisma.$queryRaw`SELECT * FROM users WHERE id = ${id}`;
await prisma.$queryRaw(Prisma.sql`SELECT * FROM users WHERE id = ${id}`);Dynamic ORDER BY
Parameterization doesn't work for column names and SQL keywords. If you allow dynamic sort columns:
// ✓ Whitelist allowed columns
const ALLOWED_SORT_COLUMNS = ['name', 'created_at', 'updated_at'];
const column = ALLOWED_SORT_COLUMNS.includes(req.query.sort)
? req.query.sort
: 'created_at';
const rows = await db.query(
`SELECT * FROM projects ORDER BY ${column} DESC LIMIT $1`,
[limit]
);Testing for SQL injection
# Using sqlmap (comprehensive automated testing)
sqlmap -u "http://localhost:3000/users?id=1" --level=3
# Manual test — submit this as input and check for errors or unexpected data:
' OR '1'='1
'; DROP TABLE users; --
1; SELECT * FROM users--Defense in depth
Even with parameterized queries, use the principle of least privilege: your app's DB user should only have SELECT, INSERT, UPDATE, DELETE on the tables it needs. DROP TABLE, TRUNCATE, and schema modifications should not be possible from the application user, limiting the blast radius of any vulnerability.