DocsHub
Security

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.

Attacker sends:{ email: 'admin@school.com', password: { '$gt': '' } } Your code:User.findOne({ email: req.body.email, password: req.body.password}) MongoDB receives:findOne({ email: 'admin@school.com', password: { '$gt': '' }}) $gt: '' means'password greater thanempty string'— true for ANY password ❌ Query matches the adminuser without knowingthe real password

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' || true

This 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:

  1. Type check first{ "$ne": "" } is an object, typeof returns "object", not "string" — rejected immediately
  2. 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-sanitize
const 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.bodyreq.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=10th

A malicious request, using Express's bracket syntax for nested query parameters:

GET /api/students?grade[$ne]=null

Express 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

AttackHow it worksDefense
Auth bypass{ "$ne": "" } as passwordType-check, never put password in query filter
Blind data extraction{ "$regex": "^admin" }Type-check, use allow-lists for searchable fields
$where injectionRaw JavaScript in queryNever use $where/$function with user input
Query param injection?grade[$ne]=nullValidate 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.

On this page