Preventing SQL Injection in Node.js and Express
A practical developer guide to eliminating SQL injection in Node.js and Express backends using pg, mysql2, query builders, and ORMs.

Node.js and Express power millions of web applications and REST APIs. Because Express is minimalist and unopinionated regarding database integration, engineering teams frequently implement direct SQL queries using drivers like pg (PostgreSQL) or mysql2. Without strict architectural conventions, developers often introduce severe SQL injection vulnerabilities through string interpolation and dynamic query concatenation.
To understand the core mechanics of how attackers exploit query boundaries, read our comprehensive primer on SQL injection mechanics and threat models. In this tutorial, we examine how to prevent SQL injection across the Node.js and Express ecosystem.
Where SQL injection vulnerabilities originate in Express
In an Express route handler, incoming client data arrives via req.body, req.query, or req.params. The vulnerability arises when this unvalidated data is interpolated directly into raw SQL strings:
// DANGEROUS: Vulnerable Express route
app.post('/api/users/search', async (req, res) => {
const { username } = req.body;
// Untrusted string concatenation creates an exploitable SQLi entry point
const query = `SELECT id, username, email FROM users WHERE username = '${username}'`;
const result = await pool.query(query);
res.json(result.rows);
});
If an attacker transmits ' OR '1'='1' --, the database executes:
SELECT id, username, email FROM users WHERE username = '' OR '1'='1' --'
This exfiltrates the entire user registry. Even worse, if multiple statements are enabled in database connections, stacked queries can alter or drop database tables entirely.
Securing PostgreSQL with node-postgres (pg)
The node-postgres driver provides native support for parameterized queries using numbered placeholders ($1, $2, $3). The database engine separates query compilation from argument evaluation:
// SECURE: Parameterized query in Express with pg
import { Router } from 'express';
import pool from './db.js';
const router = Router();
router.post('/api/users/search', async (req, res, next) => {
try {
const { username } = req.body;
// Parameterized SQL query: $1 acts as a strict literal placeholder
const text = 'SELECT id, username, email FROM users WHERE username = $1 AND active = true';
const values = [username];
const { rows } = await pool.query(text, values);
res.json(rows);
} catch (err) {
next(err);
}
});
export default router;
Dynamic filtering with safe parameter lists
When building dynamic search queries where filters are optional, never concatenate SQL fragments without tracking parameter indexes:
// SECURE: Dynamic multi-field filter builder
export async function searchProducts({ category, minPrice, maxPrice }) {
const conditions = [];
const values = [];
if (category) {
values.push(category);
conditions.push(`category = $${values.length}`);
}
if (typeof minPrice === 'number') {
values.push(minPrice);
conditions.push(`price >= $${values.length}`);
}
if (typeof maxPrice === 'number') {
values.push(maxPrice);
conditions.push(`price <= $${values.length}`);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const text = `SELECT id, name, price, category FROM products ${whereClause} ORDER BY id DESC LIMIT 50`;
return pool.query(text, values);
}
Securing MySQL with mysql2
The mysql2 driver supports both standard escaped queries and true binary prepared statements via connection.execute():
// SECURE: Binary prepared statements in mysql2
import mysql from 'mysql2/promise';
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
waitForConnections: true,
connectionLimit: 10
});
export async function getUserById(userId) {
// Use execute() instead of query() for true prepared statements
const sql = 'SELECT id, username, email FROM users WHERE id = ? AND deleted_at IS NULL';
const [rows] = await pool.execute(sql, [userId]);
return rows[0] || null;
}
[!IMPORTANT] Always prefer
pool.execute()overpool.query()inmysql2.execute()utilizes MySQL's binary protocol prepared statements, delivering both superior performance through execution plan caching and robust injection immunity.
Query builders and ORMs: Knex and Prisma
High-level database abstractions eliminate direct SQL manipulation, but developers must remain vigilant when using raw query extensions.
Safe Knex.js query building
import knex from 'knex';
const db = knex({ client: 'pg', connection: process.env.DATABASE_URL });
// SECURE: Native Knex query builder syntax
const users = await db('users')
.select('id', 'username', 'email')
.where('status', '=', req.query.status)
.whereILike('username', `%${req.query.search}%`);
// DANGEROUS: knex.raw with string interpolation
// db.raw(`SELECT * FROM users WHERE status = '${req.query.status}'`); // VULNERABLE
// SECURE: knex.raw with parameter bindings
const secureRaw = await db.raw(
'SELECT * FROM users WHERE status = :status AND score > :score',
{ status: req.query.status, score: 50 }
);
Prisma ORM best practices
Prisma parameterizes all queries generated by its client API:
// SECURE: Prisma Client method calls
const user = await prisma.user.findFirst({
where: {
email: req.body.email,
isActive: true
}
});
// SECURE: Prisma $queryRaw with tagged template literal
// Prisma automatically converts template expressions into parameterized values
const results = await prisma.$queryRaw`
SELECT id, email, created_at
FROM users
WHERE email = ${req.body.email}
`;
// CAUTION: $queryRawUnsafe disables parameterization
// await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE email = '${email}'`); // NEVER DO THIS
When structuring complex enterprise backends, secure database interactions must be paired with strict token validation; check our engineering guide on JWT security pitfalls and refresh rotation to guard your session perimeter.
Common developer mistakes in Node.js database layers
| Anti-Pattern | Root Cause | Correct Engineering Pattern |
|---|---|---|
| Dynamic Table / Column Names | Parameterized queries only sanitize values, not identifiers | Whitelist valid column/table names against an allowed set |
IN Clause Concatenation |
Difficulty passing variable array lengths into $1 |
Use WHERE id = ANY($1::int[]) in PostgreSQL or expand ? placeholders |
| String Escaping Functions | Relying on custom regex or replace(/'/g, "''") |
Use database driver native prepared statements exclusively |
| Disabling Parameterization in Raw Queries | Using prisma.$queryRawUnsafe or knex.raw template strings |
Always pass parameter replacement objects or arrays |
| Leaking SQL Error Messages in Express | Uncaught exceptions revealing query schema in HTTP responses | Implement centralized error handling middleware returning generic responses |
Handling dynamic column sorting safely
SQL drivers do not allow column names or ASC/DESC keywords to be passed as query parameters. You must validate them against an explicit whitelist:
// SECURE: Whitelisted sorting logic
const ALLOWED_SORT_COLUMNS = new Set(['created_at', 'username', 'email', 'id']);
const ALLOWED_DIRECTIONS = new Set(['ASC', 'DESC']);
export async function getSortedUsers(sortBy = 'created_at', direction = 'DESC') {
const column = ALLOWED_SORT_COLUMNS.has(sortBy) ? sortBy : 'created_at';
const order = ALLOWED_DIRECTIONS.has(direction.toUpperCase()) ? direction.toUpperCase() : 'DESC';
// Safe to interpolate because column and order are strictly verified against Set
const sql = `SELECT id, username, email FROM users ORDER BY ${column} ${order} LIMIT 100`;
const { rows } = await pool.query(sql);
return rows;
}
For maintaining long-term code quality across backend teams, follow our clean code and code review habits.
Production Express defensive checklist
Before deploying your Node.js service to production, ensure these controls are verified:
- Every database call uses driver parameterized queries (
$1,?), ORM abstractions, or boundrawcalls. - Table names, order columns, and direction flags are validated against rigid JavaScript
Setwhitelists. - Database credentials utilize unprivileged accounts without schema alteration rights.
- Global Express error handler catches database rejections and emits opaque 500 status codes without error traces.
- Automated static analysis (e.g., ESLint security plugins or Semgrep) is configured in your CI/CD pipeline.
Bottom line
Preventing SQL injection in Node.js and Express does not require complex custom sanitization algorithms. By adopting native parameterized queries in node-postgres and mysql2, leveraging the type safety of modern ORMs, and enforcing identifier whitelisting, you eliminate SQL injection vectors entirely from your API services.
Sources: Node-Postgres Documentation on Parameterized Queries, MySQL2 Execution Protocol Specification, OWASP Node.js Security Best Practices, CWE-89 (evaluated November 2026).


