NoSQL Injection
Learn what NoSQL injection is, how MongoDB query operators can be exploited through user input, and how to defend against it.
NoSQL Injection
You have probably heard of SQL injection — attackers inject SQL code through input fields to manipulate database queries. MongoDB is not immune to a similar problem, called NoSQL injection. Instead of injecting SQL syntax, attackers inject MongoDB query operators through input that your application trusts.
We touched on this briefly in the previous file. This file covers the full picture — how the attack works, real vulnerable examples, and how to defend against it properly.
How NoSQL Injection Works
The root cause is always the same — user input is used directly inside a MongoDB query object, without checking its type or shape.
JSON allows objects as values. If your code passes req.body.password directly into a query filter, and the attacker sends an object instead of a string, MongoDB interprets that object as query operators — not as a value to compare against.
Attack 1 — Authentication Bypass
This is the most well-known NoSQL injection attack, and the one we previewed in the previous file.
Vulnerable Code
// VULNERABLE
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email, password });
if (user) {
return res.json({ message: 'Logged in', userId: user._id });
}
res.status(401).json({ message: 'Invalid credentials' });
});Normal Request (Legitimate)
POST /api/login
{
"email": "ali@school.com",
"password": "mypassword123"
}This becomes:
db.users.findOne({ email: "ali@school.com", password: "mypassword123" })A normal, safe query — finds the user only if both fields match exactly.
Malicious Request
POST /api/login
{
"email": "admin@school.com",
"password": { "$ne": "" }
}This becomes:
db.users.findOne({ email: "admin@school.com", password: { $ne: "" } })$ne: "" means "password is not equal to an empty string" — true for every user with any password set. The query returns the admin user. The attacker is now logged in as admin — without knowing the password at all.
Even Without Knowing Any Email
POST /api/login
{
"email": { "$ne": null },
"password": { "$ne": null }
}This becomes:
db.users.findOne({ email: { $ne: null }, password: { $ne: null } })This matches the first user in the entire collection — whoever that happens to be. If your _id ordering happens to put an admin first, the attacker is logged in as that admin.
Attack 2 — Data Extraction Through Operators
Beyond login bypass, injected operators can be used to extract information through application behavior — even without direct access to results.
Example — "Forgot Password" Endpoint
// VULNERABLE
app.post('/api/forgot-password', async (req, res) => {
const { email } = req.body;
const user = await User.findOne({ email });
if (user) {
// sends reset email...
return res.json({ message: 'Reset link sent' });
}
res.status(404).json({ message: 'Email not found' });
});An attacker sends:
{ "email": { "$regex": "^admin" } }This becomes:
db.users.findOne({ email: { $regex: "^admin" } })If this returns "Reset link sent", the attacker has confirmed that an email starting with "admin" exists in the system — without knowing the full email. By repeating this with different regex patterns, an attacker can slowly extract email addresses character by character. This is called a blind injection — the attacker cannot see the data directly, but can infer it from the application's response.
The $where Operator — Especially Dangerous
$where allows you to run arbitrary JavaScript as part of a query. If user input ever reaches a $where clause, it is essentially remote code execution.
// EXTREMELY VULNERABLE — never do this
app.get('/api/students/search', async (req, res) => {
const { filter } = req.query;
const students = await Student.find({
$where: filter // user-controlled JavaScript!
});
res.json(students);
});GET /api/students/search?filter=this.name=='Ali' || trueThis would return every student regardless of name, because || true makes the condition always pass. Worse, an attacker could write JavaScript that consumes server resources or attempts to access the file system, depending on the MongoDB server's configuration.
Never use $where, mapReduce, or $function with any value that comes from user input — directly or indirectly. These operators execute JavaScript on the database server. If you find yourself wanting to use $where, there is almost always a safer way to express the same query using standard operators.
The Core Defense — Type Checking
The single most effective defense against NoSQL injection is simple — MongoDB query filter values should always be primitives (strings, numbers, booleans, dates) that come from your application's expectations, never raw objects from user input.
Fix for the Login Example
// SAFE
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
// Reject if either field is not a string
if (typeof email !== 'string' || typeof password !== 'string') {
return res.status(400).json({ message: 'Invalid input' });
}
const user = await User.findOne({ email: email.toLowerCase() });
if (!user || !(await user.comparePassword(password))) {
return res.status(401).json({ message: 'Invalid email or password' });
}
res.json({ message: 'Logged in', userId: user._id });
});Two changes make this safe:
- Type check first —
{ "$ne": "" }is an object,typeofreturns"object", not"string"— rejected immediately - Password never enters the query filter — we fetch the user by email only, then use
bcrypt.compare()to check the password in application code, where it is just a string comparison, not a MongoDB operator
Using a Sanitization Library
For broader protection across your whole app, use a library that strips MongoDB operators from user input automatically.
express-mongo-sanitize
npm install express-mongo-sanitizeconst express = require('express');
const mongoSanitize = require('express-mongo-sanitize');
const app = express();
app.use(express.json());
// Remove any keys starting with '$' or containing '.' from req.body, req.query, req.params
app.use(mongoSanitize());With this middleware applied globally, the malicious request:
{ "email": "admin@school.com", "password": { "$ne": "" } }becomes, after sanitization:
{ "email": "admin@school.com", "password": {} }The $ne key is stripped because it starts with $. The resulting query:
db.users.findOne({ email: "admin@school.com", password: {} })This will not match any real user — password: {} does not equal any stored password hash. The injection is neutralized.
express-mongo-sanitize is a good defense-in-depth measure — apply it globally as middleware so every request is automatically cleaned. But it should not be your only defense. Combine it with explicit type checking and input validation (Joi or similar), as covered in the previous file. Defense in depth means multiple layers — if one fails, others still protect you.
Validating Query Parameters Too
Injection is not limited to req.body — req.query and req.params are just as dangerous, especially in Express where query strings can be parsed into nested objects.
Vulnerable Search Endpoint
// VULNERABLE
app.get('/api/students', async (req, res) => {
const { grade } = req.query;
const students = await Student.find({ grade });
res.json(students);
});A normal request:
GET /api/students?grade=10thA malicious request, using Express's bracket syntax for nested query parameters:
GET /api/students?grade[$ne]=nullExpress parses this into:
req.query.grade = { $ne: "null" } // an object, not a string!The query becomes:
db.students.find({ grade: { $ne: "null" } })This returns every student whose grade is not the literal string "null" — which is all of them. Combined with requireRole('teacher') checks that assume grade filters down to a specific class, this could expose data the attacker should not see.
The Fix
// SAFE
app.get('/api/students', async (req, res) => {
const { grade } = req.query;
const validGrades = ["9th", "10th", "11th", "12th"];
if (grade && (typeof grade !== 'string' || !validGrades.includes(grade))) {
return res.status(400).json({ message: 'Invalid grade parameter' });
}
const filter = grade ? { grade } : {};
const students = await Student.find(filter);
res.json(students);
});Checklist — Defending Against NoSQL Injection
✅ Type-check every value before using it in a query filter
✅ Never put user-supplied passwords directly into a query — fetch by a unique field, then compare with bcrypt
✅ Use a validation library (Joi) to define exact expected input shapes
✅ Apply express-mongo-sanitize globally as a defense-in-depth layer
✅ Validate req.query and req.params, not just req.body
✅ Never use $where, $function, or mapReduce with any user-influenced value
✅ Use enum / allow-lists for fields with a fixed set of valid values (grade, role, status)Complete Safe Login — Final Version
Bringing together everything from this file and the previous one:
const express = require('express');
const mongoSanitize = require('express-mongo-sanitize');
const Joi = require('joi');
const User = require('./models/User');
const app = express();
app.use(express.json({ limit: '100kb' }));
app.use(mongoSanitize());
const loginSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).required()
});
app.post('/api/login', async (req, res) => {
// Layer 1 — payload size already limited by express.json
// Layer 2 — mongo-sanitize already stripped any $ operators
// Layer 3 — Joi validates exact shape and type
const { error, value } = loginSchema.validate(req.body);
if (error) {
return res.status(400).json({ message: 'Invalid email or password' });
}
const { email, password } = value;
// Layer 4 — password never enters a query filter
const user = await User.findOne({ email: email.toLowerCase() });
if (!user || !(await user.comparePassword(password))) {
return res.status(401).json({ message: 'Invalid email or password' });
}
res.json({ message: 'Logged in', userId: user._id });
});Four independent layers — payload limits, sanitization, schema validation, and safe query construction. An attacker would need to bypass all four to succeed.
Quick Reference
| Attack | How it works | Defense |
|---|---|---|
| Auth bypass | { "$ne": "" } as password | Type-check, never put password in query filter |
| Blind data extraction | { "$regex": "^admin" } | Type-check, use allow-lists for searchable fields |
$where injection | Raw JavaScript in query | Never use $where/$function with user input |
| Query param injection | ?grade[$ne]=null | Validate req.query types, not just req.body |
The single habit that prevents almost all NoSQL injection — before any value from req.body, req.query, or req.params goes into a MongoDB query filter, ask "do I know for certain this is a string/number/boolean, and not an object?" If you cannot answer yes with confidence, add a type check. This one question, asked consistently, closes the door on this entire category of vulnerability.
Schema Validation as a Security Layer
Learn why MongoDB schema validation is also a security feature — defending against malformed data, oversized payloads, and type confusion attacks.
Change Streams
Learn how to use MongoDB change streams to listen for real-time data changes with watch(), and build live features like notifications.