Skip to content
Node.js nd security 4 min read

Preventing SQL & NoSQL Injection

Injection attacks happen when untrusted user input is woven directly into a database query, letting an attacker change the query’s meaning rather than just its data. SQL injection remains one of the most damaging web vulnerabilities because a single unescaped string can leak entire tables, bypass authentication, or destroy data. The fix is almost always the same: never build queries by concatenating strings — let the driver separate code from data using parameterized (prepared) statements. The same discipline applies to NoSQL stores like MongoDB, where attackers inject query operators instead of SQL keywords.

How injection happens

Consider a login handler that builds SQL by gluing the user’s input straight into the query text. The database has no way to tell which characters are code and which are data, so it trusts everything it receives.

// VULNERABLE — never do this
import mysql from "mysql2/promise";

const db = await mysql.createConnection({ host: "localhost", user: "app", database: "shop" });

async function login(email, password) {
  const sql = `SELECT * FROM users WHERE email = '${email}' AND password = '${password}'`;
  const [rows] = await db.query(sql);
  return rows[0];
}

If an attacker submits ' OR '1'='1 as the email, the executed query becomes:

SELECT * FROM users WHERE email = '' OR '1'='1' AND password = '...'

The OR '1'='1' is always true, so the database returns the first user — often an admin — without a valid password. String concatenation is the root cause: the input crossed the boundary from data into executable SQL.

Warning: Escaping input by hand (stripping quotes, regex filtering) is fragile and routinely bypassed. The only robust defense is to keep input as data via parameters — let the driver do the binding.

Parameterized queries with SQL

Parameterized statements send the SQL template and the values to the database separately. Placeholders (? in MySQL, $1 in PostgreSQL) mark where values go, and the driver binds them as typed data that can never alter the query structure.

// SAFE — mysql2 with placeholders
async function login(email, password) {
  const sql = "SELECT * FROM users WHERE email = ? AND password = ?";
  const [rows] = await db.execute(sql, [email, password]);
  return rows[0];
}

The same input ' OR '1'='1 is now treated as a literal email string and simply matches no rows. For PostgreSQL with the pg driver, use numbered placeholders:

import pg from "pg";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

async function findUser(email) {
  const result = await pool.query(
    "SELECT id, email FROM users WHERE email = $1",
    [email],
  );
  return result.rows[0];
}

Use execute() (mysql2) or parameterized query() (pg) — both compile the statement once and bind values safely. Note that placeholders work for values, not identifiers: you cannot parameterize table or column names. Validate those against an allowlist instead.

const allowedSort = { name: "name", created: "created_at" };
const column = allowedSort[req.query.sort] ?? "created_at"; // never interpolate raw input
const [rows] = await db.execute(`SELECT * FROM products ORDER BY ${column} LIMIT 20`);

Driver and ORM options

Most production code uses a query builder or ORM, which parameterizes automatically — as long as you avoid their raw-string escape hatches.

ToolSafe APIDangerous escape hatch
mysql2execute(sql, params)template-literal in query()
pgquery(text, params)string-concatenated query()
Prismaprisma.user.findMany({ where }), $queryRaw\…“ (tagged)$queryRawUnsafe(str)
Knex.where({ email }), ? bindings.whereRaw(str) with interpolation
Sequelizereplacements / bind options${} inside sequelize.query()

Prisma’s tagged template $queryRaw is safe because it parameterizes interpolated values; the Unsafe variants are not.

import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();

// SAFE — tagged template binds the value as a parameter
const users = await prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`;

NoSQL injection in MongoDB

MongoDB queries are JSON objects, so there is no SQL to escape — but an attacker can inject query operators if you pass raw request bodies straight into a query. If a JSON login request sends { "email": "[email protected]", "password": { "$ne": null } }, the $ne operator matches any non-null password.

// VULNERABLE — req.body values may contain operators like $ne, $gt
const user = await users.findOne({ email: req.body.email, password: req.body.password });

Defend by coercing inputs to primitives and rejecting objects where you expect strings, then validate with a schema:

import { z } from "zod";

const Login = z.object({
  email: z.string().email(),
  password: z.string().min(1),
});

async function login(req, res) {
  const { email, password } = Login.parse(req.body); // throws if value is an object
  const user = await users.findOne({ email, password });
  if (!user) return res.status(401).json({ error: "Invalid credentials" });
  res.json({ id: user._id });
}

You can also strip $-prefixed keys globally with express-mongo-sanitize, but explicit schema validation is more precise and self-documenting.

Tip: Treat every request field as potentially an object, array, or operator. Validating types at the boundary stops both NoSQL operator injection and a class of type-confusion bugs.

Best practices

  • Always use parameterized or prepared statements — never concatenate or interpolate user input into query text.
  • Reserve placeholders for values; validate table/column/sort identifiers against an explicit allowlist.
  • Prefer ORM/query-builder safe APIs and avoid their Raw/Unsafe escape hatches.
  • For MongoDB, coerce and schema-validate inputs so request bodies can’t smuggle query operators like $ne or $gt.
  • Grant database accounts least privilege so a successful injection has limited blast radius.
  • Centralize validation at the request boundary and reject unexpected types before they reach the database.
Last updated June 14, 2026
Was this helpful?