On this page

SQL and NoSQL Injection

What Injection Is

Injection happens when user input is treated as part of a query's code instead of as plain data. An attacker types something that changes what the query does: reading other users' data, skipping checks, or deleting tables.

Think of a form letter with a blank: "Please deliver order number ____." If someone writes "901, and also give me every other customer's address" in the blank, and the clerk follows the whole sentence, the blank has become an instruction. The fix is a clerk who treats whatever is in the blank as only a number.

SQL Injection

Our food delivery app has an endpoint that shows one order: /orders?id=901. A first version builds the SQL by joining strings:

// VULNERABLE: user input glued into the SQL text
const id = req.query.id;
const result = await pool.query("SELECT * FROM orders WHERE id = " + id);

Normal input works fine. Malicious input changes the query:

1id = 901
2 → SELECT * FROM orders WHERE id = 901
3 → one order ✓
4
5id = 901 OR 1=1
6 → SELECT * FROM orders WHERE id = 901 OR 1=1
7 → 1=1 is always true → EVERY order, with every customer's address

Worse inputs can add extra statements, read other tables, or change data, depending on the database and driver.

The Fix: Parameterized Queries

A parameterized query (also called a prepared statement) sends the SQL and the values separately. The SQL has placeholders ($1 in pg, ? in JDBC). The database treats each value as data only, never as SQL code.

// SAFE: $1 is a placeholder, the value is sent separately
const result = await pool.query(
'SELECT * FROM orders WHERE id = $1 AND user_id = $2',
[req.query.id, req.user.id] // also check the order belongs to this user
);
1id = 901 OR 1=1
2 → SQL: SELECT * FROM orders WHERE id = $1
3 → value: "901 OR 1=1"
4 → not a valid number → error, no data leaked

Placeholders work for values only, not for table or column names. For something like a user-chosen sort column, use an allowlist (a fixed list of allowed values):

// sort column chosen by the user: only allow known names
const allowed = { date: 'created_at', amount: 'amount' };
const column = allowed[req.query.sort] || 'created_at';
const result = await pool.query(
'SELECT * FROM orders WHERE user_id = $1 ORDER BY ' + column + ' DESC',
[req.user.id]
);

NoSQL Injection

MongoDB doesn't use SQL strings, so many people assume it's safe. But it has its own version: operator injection. MongoDB queries are objects, and keys starting with $ are operators (commands) like $gt (greater than) or $ne (not equal).

Our app checks a coupon code sent in a JSON body:

// VULNERABLE: req.body.code is used directly in the filter
app.post('/apply-coupon', async (req, res) => {
const coupon = await Coupon.findOne({ code: req.body.code, used: false });
if (!coupon) return res.status(400).send('Invalid coupon');
// apply discount...
});

Express parses JSON into objects, so the attacker can send an object instead of a string:

1Normal: { "code": "FIRST50" }
2 → findOne({ code: "FIRST50", used: false }) → that coupon only ✓
3
4Attack: { "code": { "$ne": "" } }
5 → findOne({ code: { $ne: "" }, used: false })
6 → "code is not empty" matches ANY unused coupon
7 → attacker gets a discount without knowing a code

The same trick on a login filter like { "$gt": "" } can match accounts without the real value.

Fixing NoSQL Injection

FixHow
Force the typeConvert input to a string (or number) before using it in a filter
Validate inputReject requests where the field isn't a string, using Joi, Zod, or express-validator
Sanitize filtersMongoose's sanitizeFilter option wraps operator objects so they're treated as plain values
Strip $ keysMiddleware like express-mongo-sanitize removes keys starting with $ from input
Avoid $where$where runs JavaScript inside MongoDB; never pass user input to it
// 1. force the type
const code = String(req.body.code);
const coupon = await Coupon.findOne({ code: code, used: false });
// 2. or validate and reject
if (typeof req.body.code !== 'string') {
return res.status(400).send('Invalid coupon');
}
// 3. or sanitize all filters globally
mongoose.set('sanitizeFilter', true);
// { code: { $ne: '' } } is now treated as a literal value, not an operator

Defense in Depth

Parameterized queries and type checks are the main fix. These add extra protection if something slips through:

LayerWhat it does
Least privilegeThe app's database user can only read and write its own tables, never DROP or reach admin data
Ownership checksAlways filter by the logged-in user (AND user_id = $2), so a valid id can't fetch someone else's order
ORMs and query buildersParameterize by default, as long as you don't build raw strings yourself
Generic error messagesDon't show raw database errors to users; they reveal table and column names

Interview Angle

How to Explain it in an Interview

The 30-Second Spoken Pitch
"Injection happens when user input becomes part of the query's code instead of staying data. In SQL, gluing input into a query string lets someone send something like 901 OR 1=1 and read every order. The fix is parameterized queries, with placeholders like dollar one in pg or question marks in a JDBC PreparedStatement, so the database always treats the value as data. Placeholders don't work for column names, so for things like sort order I use an allowlist. MongoDB has operator injection: if I pass request JSON straight into a filter, someone can send an object like dollar-ne empty string and match any record. I prevent that by forcing input to a string, validating types, or turning on Mongoose sanitizeFilter. On top of that, the database user gets least privilege and every query checks the record belongs to the current user."

Common mistakes:

  • Thinking MongoDB can't be injected because it doesn't use SQL.
  • Thinking an ORM makes you safe even when you build query strings by hand.
  • Escaping quotes manually instead of using placeholders.

Summary

1Injection → user input becomes part of the query's code
2SQL → string concatenation → "901 OR 1=1" leaks all rows
3SQL fix → parameterized queries ($1, ?), allowlist for column names
4NoSQL → JSON objects with $ operators → { "$ne": "" } matches anything
5NoSQL fix → force types, validate, sanitizeFilter / mongo-sanitize, no $where
6Extra layers → least privilege, ownership checks, generic errors

With safe queries in place, the next question is how to write them. Next, ORM/ODM trade-offs compares tools like Prisma, Sequelize, and Mongoose with raw queries.

Next TopicORM/ODM Trade-offs