Skip to content
Express.js ex security 4 min read

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
ApproachInjection-safe?Note
ORM model methods (findUnique)YesParameterized by the ORM
Tagged-template raw ($queryRaw)YesInterpolations become bound parameters
replacements / placeholdersYesDriver binds values
Manual string concatenationNoNever 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.query is a getter and cannot be reassigned, which breaks the older middleware. Use a maintained fork or call mongoSanitize.sanitize() directly on req.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 with express-mongo-sanitize and 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/$queryRawUnsafe call.
Last updated June 14, 2026
Was this helpful?