← Back to Blog
Node.jsNext.jsSQL InjectionNoSQL InjectionPrismaMongoDBDatabase SecurityOWASP

SQL and NoSQL Injection Prevention in Node.js: ORM Safety and Parameterized Queries [2026]

Why Injection Is Still the #1 Web Risk

OWASP has ranked injection at or near the top of the Top 10 for two decades. It's not because developers don't know about it — it's because the failure mode is so easy to introduce by accident. One template literal in a query, one user-controlled object passed to a filter, one raw() call with a concatenated string, and your entire database is readable, writable, or deletable.

The stakes are concrete: the TalkTalk breach (2015, ~157,000 customer records) and the Marriott/Starwood breach (2018, ~500 million guest records) both traced back to SQL injection. For a startup, a single injection finding during a security audit can kill an enterprise deal — and the fix is a fraction of the cost of the remediation.

The uncomfortable truth this article is about: your ORM is not a security boundary. Prisma, Knex, and Sequelize protect you by default — and give you sharp, unguarded APIs for when you need raw SQL. Every injection in production Node.js code I've audited came through one of three doors: string concatenation into raw SQL, orderBy/identifier injection, or NoSQL operator injection.

How SQL Injection Actually Happens in Node.js

The vulnerable pattern is always the same: attacker-controlled input interpolated into a SQL string.

js
// ❌ NEVER do this — this is the vulnerability, in its purest form
import pg from "pg";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

app.post("/api/login", async (req, res) => {
  const { email, password } = req.body;

  const query = `
    SELECT * FROM users
    WHERE email = '${email}' AND password = '${password}'
  `;
  const result = await pool.query(query);
  // ...
});

An attacker sends:

POST /api/login
{"email": "' OR '1'='1' --", "password": "anything"}

The query becomes:

sql
SELECT * FROM users WHERE email = '' OR '1'='1' --' AND password = 'anything'

'1'='1' is always true, and -- comments out the rest. The query returns the first row in the users table — often the admin. No password needed.

The same bug appears with mysql2:

js
// ❌ Unsafe
const [rows] = await connection.query(
  `SELECT * FROM users WHERE email = '${email}'`
);

Parameterized Queries: The Only Real Fix

The fix is not escaping — it's parameterization. The SQL statement and the data travel to the database separately. The database engine compiles the statement with $1/? placeholders and binds the values as data, never as code. Quoting tricks inside the value are inert.

js
// ✅ Safe — pg prepared statement style
const query = `
  SELECT * FROM users
  WHERE email = $1 AND password_hash = $2
`;
const result = await pool.query(query, [email, passwordHash]);
js
// ✅ Safe — mysql2 .execute() uses real prepared statements
const [rows] = await connection.execute(
  "SELECT * FROM users WHERE email = ? AND tenant_id = ?",
  [email, tenantId]
);

A note on mysql2: connection.query() performs client-side escaping — it interpolates escaped values into the string. It's safer than manual concatenation, but connection.execute() sends a true prepared statement to the server. Prefer execute() for anything security-sensitive (and for the performance win of statement caching).

Rule of thumb: if a SQL string contains a $1, ?, or :name placeholder, the data can't become SQL. If it contains a '${...}', it can.

ORMs: Safe by Default, Dangerous by Design

ORMs are the single biggest win for injection resistance — the query builder parameterizes everything you express through its API. The risk shifts to the escape hatches: raw query methods, dynamic identifiers, and fragment composition.

Prisma

ts
// ✅ Safe — the query builder parameterizes everything
const users = await prisma.user.findMany({
  where: { email, tenantId },
});

// ✅ Safe — tagged template: ${values} are bound as parameters
const rows = await prisma.$queryRaw`
  SELECT id, email FROM users
  WHERE tenant_id = ${tenantId} AND email = ${email}
`;

The tagged-template form ($queryRaw) is safe because it's a tagged template — Prisma parses the interpolations and binds them as parameters. The danger is the non-tagged variant:

ts
// ❌ Unsafe — plain string interpolation, nothing is bound
const rows = await prisma.$queryRawUnsafe(
  `SELECT * FROM users WHERE email = '${email}'`
);

$queryRawUnsafe is the exact same vulnerability as raw pg, with ORM-colored glasses. It exists for dynamic query construction — and that's precisely when most people misuse it.

Knex

js
// ✅ Safe
const users = await knex("users").where({ email, tenant_id: tenantId });

// ✅ Safe — ? placeholders are bound
const rows = await knex.raw("SELECT * FROM users WHERE email = ?", [email]);

// ❌ Unsafe — concatenation
const rows = await knex.raw(`SELECT * FROM users WHERE email = '${email}'`);

Sequelize

js
// ✅ Safe
const users = await User.findAll({ where: { email, tenantId } });

// ✅ Safe — :name replacements are parameterized
const rows = await sequelize.query(
  "SELECT * FROM users WHERE email = :email",
  { replacements: { email }, type: QueryTypes.SELECT }
);

// ❌ Unsafe — interpolation
const rows = await sequelize.query(`SELECT * FROM users WHERE email = '${email}'`);

The Identifier Injection You Forgot: ORDER BY, GROUP BY, and Column Names

Placeholders work for values. They do not work for identifiers — table names, column names, sort directions. You cannot write ORDER BY ? in most databases (PostgreSQL's ORDER BY $1 binds a value, not a column — and it will happily sort by a constant). So developers fall back to interpolation:

js
// ❌ Unsafe — classic orderBy injection
const users = await knex("posts")
  .orderBy(req.query.sort, req.query.direction);

req.query.direction = "asc); DROP TABLE posts;--" — Knex builds ORDER BY created_at asc); DROP TABLE posts;--, and if the driver allows multi-statement queries, you've just lost the table. Even without multi-statements, an attacker can often extract data via error messages or time-based blind injection through the sort column.

The fix is an allowlist, always:

js
// ✅ Safe — identifiers come from YOUR code, never from the client
const SORT_COLUMNS = new Set(["created_at", "title", "likes", "views"]);
const SORT_DIRECTIONS = new Set(["asc", "desc"]);

const sort = SORT_COLUMNS.has(req.query.sort) ? req.query.sort : "created_at";
const direction = SORT_DIRECTIONS.has(req.query.direction) ? req.query.direction : "desc";

const posts = await knex("posts").orderBy(sort, direction);

This rule extends to Prisma's Prisma.raw() (used for dynamic ORDER BY in $queryRaw), dynamic table names in knex.raw(), and any GROUP BY/SELECT column built from input. If it's an identifier, it must come from a hardcoded allowlist.

LIKE Wildcards: The Quiet Injection

Less dramatic, but exploitable: user input inside a LIKE pattern can inject wildcards and break queries (information disclosure through pattern matching) or worse when combined with a vulnerable driver.

js
// ❌ Unsafe-ish — % and _ in user input change the query's meaning
const rows = await pool.query(
  "SELECT * FROM products WHERE name LIKE $1",
  [`%${search}%`]
);

Escape the wildcards before binding:

js
function escapeLike(input) {
  return input.replace(/[\\%_]/g, (ch) => `\\${ch}`);
}

const rows = await pool.query(
  `SELECT * FROM products WHERE name LIKE $1 ESCAPE '\\'`,
  [`%${escapeLike(search)}%`]
);

NoSQL Injection: MongoDB Is Not Immune

"SQL injection doesn't apply, we use MongoDB" is a myth. NoSQL databases evaluate operator objects and, in MongoDB's case, JavaScript ($where). If your API accepts a filter object from the client and passes it to the query, you've shipped an injection.

Operator Injection

js
// ❌ Vulnerable — req.body IS the filter
const user = await db.collection("users").findOne({
  email: req.body.email,
  password: req.body.password,
});

An attacker sends:

json
{"email": {"$ne": null}, "password": {"$ne": null}}

MongoDB evaluates { $ne: null } as "not equal to null" — which matches the first document with any email and any password. That's a login bypass in one request. The same trick works on find(), updateOne(), and deleteMany(): {"$ne": null} on a delete filter wipes documents, {"$gt": ""} on an ID filter skips authz checks.

$where — Arbitrary JavaScript

js
// ❌ NEVER pass user input into $where — it runs as JavaScript server-side
const docs = await db.collection("users").find({
  $where: `this.email === '${email}'`,
}).toArray();

$where executes JavaScript inside the database process. With a classic string-injection payload (' || '1'=='1), an attacker gets an auth bypass; in extreme cases, code execution. MongoDB deprecated $where in favor of aggregation expressions — treat any $where in your codebase as a finding.

Regex Denial of Service

js
// ❌ User-supplied regex → catastrophic backtracking (ReDoS) against your DB
const results = await db.collection("products").find({
  name: { $regex: req.query.q },
}).toArray();

$regex accepts full regex syntax, including nested quantifiers like (a+)+$ that backtrack exponentially. A single request can pin a CPU core — and MongoDB has no per-query timeout by default.

Defensive Pattern for MongoDB

  1. Never accept objects as filters. Accept strings, validate and coerce them server-side, and build the filter explicitly:
ts
import { z } from "zod";

const searchSchema = z.object({
  email: z.string().email().max(254).optional(),
  name: z.string().min(1).max(100).optional(),
});

// ✅ Safe — the filter is constructed from validated scalars only
const parsed = searchSchema.parse(req.body);
const filter: Record<string, unknown> = {};
if (parsed.email) filter.email = parsed.email.toLowerCase();
if (parsed.name) filter.name = { $regex: escapeRegex(parsed.name), $options: "i" };

function escapeRegex(input: string) {
  return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
  1. Use Mongoose schemas with strict: true (default) and validate $/. in strings — or better, use Zod/Valibot as the trust boundary before the database layer, for both SQL and NoSQL stacks.
  2. Set a maxTimeMS on every query to bound ReDoS and runaway aggregations.

Defense in Depth: The Layers That Save You When One Fails

Even with parameterization everywhere, build the layers below — because the next developer who touches the codebase will write a $queryRawUnsafe.

1. Least-Privilege Database Accounts

Your application should connect with an account that can only do what the app does — never with the migration/DDL role:

sql
-- Migration role (CI/CD only): full DDL
CREATE ROLE app_migrator WITH LOGIN PASSWORD '...';

-- Application role: DML on the app schema, nothing else
CREATE ROLE app_user WITH LOGIN PASSWORD '...';
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;
-- No CREATE, no DROP, no ALTER, no TRUNCATE (unless the app needs it — usually not)

If injection slips through, the blast radius is the data, not the schema. DROP TABLE becomes impossible because the role lacks the privilege — this single control neuters the most famous injection payloads.

2. Validate Input at the Edge

Parameterization handles data-as-code; validation handles data-as-data. Reject malformed input before it reaches a query:

ts
const loginSchema = z.object({
  email: z.string().email().max(254),
  password: z.string().min(8).max(128),
});

app.post("/api/login", (req, res) => {
  const parsed = loginSchema.parse(req.body); // throws 400 on bad input
  // ...query with parsed.email / parsed.password
});

3. Never Leak Database Errors

Drivers return verbose errors that include the failing query. A 500 response with the raw error turns your API into an injection oracle — attackers use error messages to map the schema:

js
try {
  const rows = await pool.query(query, params);
  res.json(rows);
} catch (err) {
  // Log the full error server-side (with query id), return nothing useful
  console.error("[db]", err);
  res.status(500).json({ error: "Internal server error" });
}

4. CI Checks

Add a source scan to CI that flags the dangerous APIs. A quick grep-based gate is crude but effective:

bash
# scripts/check-injection-apis.sh — fail CI on dangerous patterns
if grep -rnE '\$queryRawUnsafe|knex\.raw\(`|sequelize\.query\(`|\.query\(`' src/; then
  echo "❌ Raw-query string interpolation detected — use parameterized APIs"
  exit 1
fi

Semgrep or CodeQL rules for pg/mysql2/Prisma are more precise — the point is to catch the escape hatches at merge time, not at audit time.

Injection Prevention Checklist

  • [ ] No string concatenation into SQL — every dynamic value goes through $1/?/:name placeholders
  • [ ] mysql2: use execute() (server-side prepared statements), not query() with escaping
  • [ ] Prisma: $queryRaw tagged template only; $queryRawUnsafe banned (grep-able CI rule)
  • [ ] Knex: .where() object syntax or knex.raw() with ? placeholders; no template literals in raw()
  • [ ] Sequelize: replacements for dynamic values; no interpolation in sequelize.query()
  • [ ] ORDER BY/GROUP BY/dynamic column names come from a hardcoded allowlist
  • [ ] LIKE patterns escape %, _, and \
  • [ ] MongoDB: no client-supplied filter objects; scalars validated with Zod before building filters
  • [ ] No $where with interpolated strings; $regex input escaped; maxTimeMS set on queries
  • [ ] App DB role has DML only — no DDL privileges; separate migration role
  • [ ] Zod/Valibot validation at the API boundary for all query-relevant fields
  • [ ] Database errors logged server-side, generic messages returned to clients
  • [ ] CI scans for raw-interpolation patterns ($queryRawUnsafe, concatenated knex.raw(), etc.)

Summary

  1. Parameterization is the fix; escaping is not. Prepared statements make data inert. Every ORM's builder API is safe — the risk concentrates in raw-query escape hatches.

  2. Treat ORM escape hatches as code review hotspots. $queryRawUnsafe, knex.raw() with template literals, and sequelize.query() with interpolation are the three most common real-world findings in Node.js audits — write CI rules that flag them.

  3. Identifiers need allowlists, not placeholders. ORDER BY, GROUP BY, and column names can't be parameterized, so they must be restricted to hardcoded sets.

  4. NoSQL injects operators, not SQL. MongoDB filter objects from the client ($ne, $gt, $where, $regex) are the NoSQL equivalent of the login bypass — validate scalars and construct filters explicitly.

  5. Layer it. Least-privilege DB roles, edge validation, and error hygiene contain the damage when a vulnerability still slips through. Injection defense is a stack, not a single query.

Next week: Race Conditions and Business Logic Flaws in Node.js — why "correct" code with check-then-act patterns loses money and data, and how to make your endpoints atomic.

Share this article:TwitterLinkedIn
JS

JS Security Audit

Senior JavaScript security consultants with 10+ years of experience.