NoSQL Injection: The Complete Practical Guide to MongoDB Injection

Preventing NoSQL Injection

How to stop NoSQL injection: validate input types, reject operator objects, disable server-side JavaScript, with per-driver code for Node, Python, and PHP.

Published

Every NoSQL injection in this cluster comes from the same root cause: a value that should have been a plain string was allowed to arrive as something else, usually an operator object, and the database obeyed it. That means the fix is also singular. Validate the type of every input before it reaches a query, and the operator injections, the auth bypasses, and the blind extractions all die at once. This page covers that principle and the concrete code for the common stacks. Build the habit on a free live target.

The one fix that matters: validate types

If a field is supposed to be a string, make sure it is a string before it touches the query. A value that arrives as an object or an array where you expected a scalar is an attack, and rejecting it closes operator injection completely. Everything else on this page is a variation on that idea or a defense-in-depth layer behind it.

Two supporting habits make it reliable:

  • Pull named fields, never spread the raw body. Build the query from specific, cast fields rather than passing a parsed request object straight to the driver.
  • Validate shape, not just presence. Checking that a field exists is not enough. Check that it is the right type.

Node and Express

The vulnerable pattern spreads untrusted input straight into the query:

// vulnerable: password can arrive as an operator object
const user = await users.findOne({
  username: req.body.username,
  password: req.body.password,
});

Fix it by forcing the types before the query runs:

const { username, password } = req.body;
if (typeof username !== "string" || typeof password !== "string") {
  return res.status(400).json({ error: "invalid input" });
}
const user = await users.findOne({ username, password });

A schema validator makes this systematic. With a library like Zod you declare the expected shape and reject anything that does not match, including an operator object where a string belongs:

import { z } from "zod";
const Login = z.object({ username: z.string(), password: z.string() });
const parsed = Login.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid input" });
const user = await users.findOne(parsed.data);

On sanitizers: express-mongo-sanitize strips keys that start with a dollar sign, but it has had bypasses, has run into trouble with newer Express internals where the query object is not writable, and does nothing about server-side JavaScript. Use it as a backstop if you want, but let type validation be the real control.

Mongoose

Mongoose casts query values to the field's declared type, so a strictly typed schema rejects an operator object on that field:

const userSchema = new mongoose.Schema({
  username: { type: String, required: true },
  password: { type: String, required: true },
});

This protection is real but conditional. It evaporates if you use Schema.Types.Mixed, query with a raw untyped object, or build a $where. Keep queried fields strictly typed and never hand Mongoose an attacker-shaped object.

Python and PyMongo

PyMongo does not validate types for you. The vulnerable pattern passes decoded JSON straight into a query:

# vulnerable: request.json["password"] may be a dict like {"$ne": None}
user = db.users.find_one({
    "username": data["username"],
    "password": data["password"],
})

Force the types first, or validate with a model:

username = data.get("username")
password = data.get("password")
if not isinstance(username, str) or not isinstance(password, str):
    abort(400)
user = db.users.find_one({"username": username, "password": password})

A validation layer such as Pydantic makes this declarative, rejecting a body where a string field arrived as an object before your handler ever runs.

PHP

PHP turns bracketed query and body parameters into arrays, which the MongoDB driver can read as operator documents. The vulnerable pattern trusts the superglobal:

// vulnerable: $_POST['password'] may be an array like ['$ne' => null]
$user = $collection->findOne([
    'username' => $_POST['username'],
    'password' => $_POST['password'],
]);

Reject non-string input explicitly, since a request like password[$ne]= makes $_POST['password'] an array:

if (!is_string($_POST['username']) || !is_string($_POST['password'])) {
    http_response_code(400);
    exit('invalid input');
}
$user = $collection->findOne([
    'username' => $_POST['username'],
    'password' => $_POST['password'],
]);

Disable server-side JavaScript

Independently of input validation, shut off the $where and aggregation JavaScript surface unless you truly need it. Start mongod with --noscripting, or set security.javascriptEnabled: false in the config file, which disables $where, $function, and $accumulator together. Never build a $where clause from user input, and prefer $expr with standard operators for per-document logic, which is also the direction MongoDB 8.0 points now that server-side JavaScript operators are deprecated.

A short checklist

  • Validate the type of every input before it reaches a query, and reject objects where a string belongs.
  • Pull and cast named fields rather than spreading raw request bodies.
  • Use a schema or validation layer (Zod, Pydantic, a typed ODM) as the systematic enforcement.
  • Keep tenant and ownership filters server-controlled so they cannot be widened by input.
  • Disable server-side JavaScript and never build $where from user input.
  • Treat sanitizer libraries as defense in depth, not as the fix.

That is the whole defense. For the offensive side in one place, see the cheat sheet, and to prove to yourself that the type check really does close the bug, break one on a free live target.

← Back to NoSQL Injection: The Complete Practical Guide to MongoDB Injection