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.
// ❌ 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:
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:
// ❌ 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.
// ✅ 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]);
// ✅ 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
// ✅ 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:
// ❌ 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
// ✅ 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
// ✅ 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:
// ❌ 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:
// ✅ 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.
// ❌ 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:
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
// ❌ Vulnerable — req.body IS the filter
const user = await db.collection("users").findOne({
email: req.body.email,
password: req.body.password,
});
An attacker sends:
{"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
// ❌ 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
// ❌ 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
- Never accept objects as filters. Accept strings, validate and coerce them server-side, and build the filter explicitly:
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, "\\$&");
}
- 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. - Set a
maxTimeMSon 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:
-- 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:
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:
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:
# 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/?/:nameplaceholders - [ ]
mysql2: useexecute()(server-side prepared statements), notquery()with escaping - [ ] Prisma:
$queryRawtagged template only;$queryRawUnsafebanned (grep-able CI rule) - [ ] Knex:
.where()object syntax orknex.raw()with?placeholders; no template literals inraw() - [ ] Sequelize:
replacementsfor dynamic values; no interpolation insequelize.query() - [ ]
ORDER BY/GROUP BY/dynamic column names come from a hardcoded allowlist - [ ]
LIKEpatterns escape%,_, and\ - [ ] MongoDB: no client-supplied filter objects; scalars validated with Zod before building filters
- [ ] No
$wherewith interpolated strings;$regexinput escaped;maxTimeMSset 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, concatenatedknex.raw(), etc.)
Summary
-
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.
-
Treat ORM escape hatches as code review hotspots.
$queryRawUnsafe,knex.raw()with template literals, andsequelize.query()with interpolation are the three most common real-world findings in Node.js audits — write CI rules that flag them. -
Identifiers need allowlists, not placeholders. ORDER BY, GROUP BY, and column names can't be parameterized, so they must be restricted to hardcoded sets.
-
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. -
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.
JS Security Audit
Senior JavaScript security consultants with 10+ years of experience.