Preventing SQL & NoSQL Injection
Injection happens whenever untrusted input is treated as part of a query’s structure rather than as plain data. In SQL it lets an attacker rewrite your WHERE clause; in MongoDB it lets them smuggle operators like $ne or $gt into a filter. The single root cause is the same — string interpolation of user input into a query — and so is the cure: keep code and data strictly separated by using parameterized queries and validated input. This page shows how to do that correctly in an Express data layer.
Why string interpolation is the bug
The classic mistake is building a query by concatenating request data directly into the command:
// DANGEROUS — never do this
app.post("/login", async (req, res) => {
const sql = `SELECT * FROM users WHERE email = '${req.body.email}'
AND password = '${req.body.password}'`;
const rows = await db.query(sql);
res.json(rows);
});
If a user submits ' OR '1'='1 as the email, the query becomes ... WHERE email = '' OR '1'='1' AND ..., which matches every row. The database cannot tell the difference between the SQL you wrote and the SQL the attacker injected, because both arrived as one undifferentiated string.
Parameterized queries
The fix is to send the query template and the values separately. The driver sends the SQL structure to the database first, then binds the values as data — they can never change the query’s meaning, regardless of their contents.
npm install pg
const { Pool } = require("pg");
const pool = new Pool();
app.post("/login", async (req, res) => {
const { email, password } = req.body;
const result = await pool.query(
"SELECT id, email FROM users WHERE email = $1 AND password_hash = $2",
[email, hash(password)]
);
res.json(result.rows);
});
The $1/$2 placeholders (? in MySQL, :name in many drivers) are bound by the driver. The malicious ' OR '1'='1 string is now compared literally against the email column and simply matches nothing.
Output:
POST /login { "email": "' OR '1'='1", "password": "x" }
HTTP/1.1 200 OK
[]
Placeholders only parameterize values. You cannot parameterize identifiers like table or column names. If a sort column comes from the user, validate it against an allow-list (
["name","created_at"]) before putting it in the query — never interpolate it.
ORMs and query builders
A well-used ORM parameterizes for you, so it is the easiest way to stay safe by default. The trap is the raw-query escape hatch, which reintroduces interpolation.
// Prisma — parameterized automatically
const user = await prisma.user.findUnique({ where: { email } });
// Sequelize — replacements are bound, not interpolated
const [rows] = await sequelize.query(
"SELECT * FROM users WHERE email = :email",
{ replacements: { email }, type: QueryTypes.SELECT }
);
// Prisma raw — use the tagged template, NOT string concatenation
const safe = await prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`;
const unsafe = await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE email = '${email}'`); // DON'T
| Approach | Injection-safe? | Note |
|---|---|---|
ORM model methods (findUnique) | Yes | Parameterized by the ORM |
Tagged-template raw ($queryRaw) | Yes | Interpolations become bound parameters |
replacements / placeholders | Yes | Driver binds values |
| Manual string concatenation | No | Never build queries with + or ${} |
NoSQL operator injection
MongoDB does not use SQL strings, so people assume it is immune. It is not. Because filters are plain objects, an attacker who controls a request value can inject a query operator. Express parses ?email[$gt]= into { email: { $gt: "" } }, turning an equality check into “any email greater than empty” — which matches everything.
// VULNERABLE if req.query.email is an object
const user = await User.findOne({ email: req.query.email, password: req.query.password });
Sending ?email[$ne]=null&password[$ne]=null makes both conditions match the first user, bypassing authentication entirely.
Sanitize with express-mongo-sanitize
The fix is to reject or strip keys that begin with $ or contain . before they reach the query. express-mongo-sanitize does this as middleware across req.body, req.query, and req.params.
npm install express-mongo-sanitize
const mongoSanitize = require("express-mongo-sanitize");
// Remove any keys starting with $ or containing . from incoming data
app.use(mongoSanitize());
In Express 5
req.queryis a getter and cannot be reassigned, which breaks the older middleware. Use a maintained fork or callmongoSanitize.sanitize()directly onreq.body/req.params, and rely on schema validation (below) for query strings.
A more robust layer is to type-check input so an object never reaches a field that expects a string. Validation libraries like Zod do this cleanly:
const { z } = require("zod");
const Login = z.object({ email: z.string().email(), password: z.string().min(8) });
app.post("/login", async (req, res) => {
const { email, password } = Login.parse(req.body); // throws on operator objects
const user = await User.findOne({ email }).select("+passwordHash");
res.json({ ok: user != null });
});
Because z.string() rejects { $ne: null }, the operator-injection vector is closed at the boundary — schema validation is the strongest defense.
Best practices
- Never build a query by concatenating or interpolating user input — pass values as bound parameters.
- Use parameterized placeholders (
$1,?,:name) for every SQL value, including in raw ORM queries. - Validate identifiers (table/column/sort names) against an explicit allow-list; they cannot be parameterized.
- Treat MongoDB filters as code: strip
$/.keys withexpress-mongo-sanitizeand type-check input. - Enforce a request schema (Zod, Joi) so objects can never reach fields expecting scalars.
- Grant the database user least privilege so a successful injection has limited blast radius.
- Prefer ORM model methods over raw queries, and audit every raw/
$queryRawUnsafecall.