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 textconst 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 = 9012 → SELECT * FROM orders WHERE id = 9013 → one order ✓4 5id = 901 OR 1=16 → SELECT * FROM orders WHERE id = 901 OR 1=17 → 1=1 is always true → EVERY order, with every customer's addressWorse 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 separatelyconst 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=12 → SQL: SELECT * FROM orders WHERE id = $13 → value: "901 OR 1=1"4 → not a valid number → error, no data leakedPlaceholders 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 namesconst 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 filterapp.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 coupon7 → attacker gets a discount without knowing a codeThe same trick on a login filter like { "$gt": "" } can match accounts without the real value.
Fixing NoSQL Injection
| Fix | How |
|---|---|
| Force the type | Convert input to a string (or number) before using it in a filter |
| Validate input | Reject requests where the field isn't a string, using Joi, Zod, or express-validator |
| Sanitize filters | Mongoose's sanitizeFilter option wraps operator objects so they're treated as plain values |
Strip $ keys | Middleware 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 typeconst code = String(req.body.code);const coupon = await Coupon.findOne({ code: code, used: false });// 2. or validate and rejectif (typeof req.body.code !== 'string') {return res.status(400).send('Invalid coupon');}// 3. or sanitize all filters globallymongoose.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:
| Layer | What it does |
|---|---|
| Least privilege | The app's database user can only read and write its own tables, never DROP or reach admin data |
| Ownership checks | Always filter by the logged-in user (AND user_id = $2), so a valid id can't fetch someone else's order |
| ORMs and query builders | Parameterize by default, as long as you don't build raw strings yourself |
| Generic error messages | Don't show raw database errors to users; they reveal table and column names |
Interview Angle
How to Explain it in an Interview
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 code2SQL → string concatenation → "901 OR 1=1" leaks all rows3SQL fix → parameterized queries ($1, ?), allowlist for column names4NoSQL → JSON objects with $ operators → { "$ne": "" } matches anything5NoSQL fix → force types, validate, sanitizeFilter / mongo-sanitize, no $where6Extra layers → least privilege, ownership checks, generic errorsWith 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.