Preventing NoSQL Injection in MongoDB

MongoDB does not parse queries from strings the way a SQL engine does, so many teams assume it is immune to injection. It is not. A MongoDB filter is a structured object, and the injection surface is the shape of that object: if an attacker can turn a value the code expects to be a string into a nested object carrying a query operator, they rewrite the query’s logic. The canonical example is an authentication bypass. A login handler that runs users.findOne({ username, password }) looks safe until a JSON request body of {"username":"admin","password":{"$ne":null}} arrives — now password is no longer a string to match but a $ne: null comparison that matches any non-null stored hash, and the attacker is logged in as admin without knowing the password.

This guide covers the MongoDB-specific mechanics — operator injection with $ne, $gt, $regex, and the JavaScript-executing $where — and the layered fixes that stop them: strict schema validation with type coercion, disabling server-side JavaScript, the real pitfalls of express-mongo-sanitize, and typed query construction. It is part of the Injection Attack Prevention guide within Vulnerability Patterns & Web Mitigation Strategies. For the relational and cross-database view of the same problem, see parameterized queries for SQL and NoSQL injection. To place this control in a wider risk model, see how defining trust boundaries frames the request body as untrusted at every layer.

Prerequisites

  • A Node.js 18+ service using the official mongodb driver or Mongoose 7+
  • Administrative access to the mongod configuration file or Atlas project settings
  • A schema validation library — this guide uses Zod, with Joi noted where it differs
  • Ability to send raw JSON request bodies (curl, Postman, or an HTTP client) for verification

Expected Outcomes

  • Every request body validated and coerced so scalars can never arrive as operator objects
  • $-prefixed keys stripped or rejected before any object reaches the driver
  • Server-side JavaScript ($where, $function, mapReduce) disabled on the database
  • Query filters assembled from typed, validated fields rather than spread from raw input

Step 1: Validate and Coerce Input Types at the Boundary

Operator injection only works because a field expected to be a scalar is allowed to arrive as an object. Close that gap first: parse the request body against a schema that permits only the primitive types you intend to query on. If password must be a string, a schema that rejects objects makes {"$ne":null} fail before a query is ever built.

The diagram below shows a request body carrying $ operators being intercepted by a validation and sanitization gate before it can reach the driver.

Schema and Sanitize Gate Blocking Operator Injection An untrusted request body with a password field set to a dollar-ne-null object flows into a validation and sanitize gate. Operator objects are rejected; coerced scalar values continue to a typed query builder and then the MongoDB driver. Request Body username: "admin" password: {$ne: null} Schema + Sanitize Gate coerce to scalar type reject $-prefixed keys strip / disable $where Rejected: 400 operator object refused Typed Query Builder { username: str, passwordHash: str } Driver Mongo untrusted

With Zod, mark the schema .strict() so unknown keys are rejected outright, and rely on the primitive type constraints to reject objects. Zod’s z.string() will not accept { $ne: null } — it throws before your handler builds a query.

import { z } from 'zod';

const LoginSchema = z.object({
  username: z.string().min(3).max(64).regex(/^[a-zA-Z0-9_.-]+$/),
  password: z.string().min(8).max(200),
}).strict(); // any extra key, including a $-prefixed one, is rejected

export function parseLogin(body: unknown) {
  const parsed = LoginSchema.safeParse(body);
  if (!parsed.success) {
    // Do not echo details back to the client; log server-side only.
    throw new HttpError(400, 'Invalid credentials payload');
  }
  return parsed.data; // username and password are guaranteed to be strings
}

The critical detail is that a JSON body of {"password":{"$ne":null}} deserialises to a JavaScript object, and z.string() rejects objects. Type coercion is the actual control: once password is provably a string, no comparison operator can ride inside it. In Joi the equivalent is Joi.string().required() combined with .prefs({ stripUnknown: false }) so that unexpected structures fail rather than being silently pruned.

Numeric and date fields need explicit coercion too. An attacker who sends {"age":{"$gt":0}} to an untyped filter turns an equality match into a range match. Use z.coerce.number() for query-string numbers so "25" becomes 25 and { $gt: 0 } is rejected as not-a-number.


Step 2: Strip Operator Keys and Disable Server-Side JavaScript

Schema validation covers the fields you know about. As defence in depth, reject any $-prefixed key on objects that will be used as query filters, and remove the most dangerous operator class — server-side JavaScript — at the database level.

The $where operator accepts a JavaScript expression that mongod evaluates per document. If user input ever reaches it, {"$where":"sleep(5000) || true"} is a trivial denial-of-service and, combined with the right expression, an information-disclosure primitive. $where, $function, $accumulator, and mapReduce all execute JavaScript. Turn the whole capability off:

# /etc/mongod.conf — disable server-side JavaScript execution entirely
security:
  javascriptEnabled: false

On MongoDB Atlas, server-side JavaScript is already disabled on shared and serverless tiers; on dedicated clusters, disable it through the advanced configuration options. With javascriptEnabled: false, any query containing $where or a $function aggregation stage fails at the server regardless of what your application layer let through.

For the operators that do not execute code but still rewrite logic ($ne, $gt, $regex, $in), strip or reject $-prefixed keys before the object reaches the driver. A recursive guard is short and auditable:

// Reject any object that carries a query operator key at any depth.
function assertNoOperators(value: unknown, path = 'body'): void {
  if (value === null || typeof value !== 'object') return;
  if (Array.isArray(value)) {
    value.forEach((v, i) => assertNoOperators(v, `${path}[${i}]`));
    return;
  }
  for (const [key, nested] of Object.entries(value)) {
    if (key.startsWith('$') || key.includes('.')) {
      throw new HttpError(400, `Illegal operator key at ${path}.${key}`);
    }
    assertNoOperators(nested, `${path}.${key}`);
  }
}

Rejecting is safer than silently stripping: a stripped $where key changes the query’s meaning without telling anyone, whereas a rejection surfaces the attack attempt in your logs. Note the . check as well — a key like role.$ne or dotted path notation can smuggle operators past a naive startsWith('$') filter.

The $regex operator deserves special attention even when it is legitimately allowed. An unbounded regex from user input such as {"$regex":"^(a+)+$"} is a ReDoS vector that pins a CPU core. If you must expose regex search, compile it yourself from an escaped literal and anchor it, rather than passing a user-supplied pattern through.


Step 3: Build Queries Through Typed Builders

The final layer removes the root cause: never spread a raw request object into a filter. Assemble the query from the individual validated fields so the filter’s shape is fixed in your source code, not by the attacker’s JSON.

import { Collection } from 'mongodb';

interface UserDoc { _id: string; username: string; passwordHash: string; role: string; }

export async function authenticate(
  users: Collection<UserDoc>,
  body: unknown,
): Promise<UserDoc | null> {
  const { username, password } = parseLogin(body); // Step 1 — both are strings

  // Filter is built from a validated scalar only. There is no path for an
  // operator object to enter: username is provably a string.
  const user = await users.findOne({ username });
  if (!user) return null;

  // Compare the password with a constant-time hash verify, never inside the query.
  const ok = await argon2.verify(user.passwordHash, password);
  return ok ? user : null;
}

Two structural rules make this robust. First, the credential comparison happens in application code with a constant-time verifier, not by matching a hash inside the query — so even a validation slip cannot turn the comparison into $ne. Second, the filter names exactly one field bound to a proven string.

Aggregation pipelines are the easiest place to reintroduce the bug, because pipeline stages are objects and it is tempting to build $match from request data. Construct each stage explicitly and cast identifiers:

import { ObjectId } from 'mongodb';

function buildOrderStats(rawUserId: string) {
  if (!ObjectId.isValid(rawUserId)) throw new HttpError(400, 'Invalid id');
  return [
    { $match: { userId: new ObjectId(rawUserId) } }, // cast, not spread
    { $group: { _id: '$status', total: { $sum: '$amount' } } },
    { $sort: { total: -1 } },
  ];
}

If you use Mongoose, keep the sanitizeFilter option enabled (it wraps user-supplied values so operator keys are treated as literal field names) and prefer typed schema paths over Schema.Types.Mixed, which disables casting and reopens the injection surface.


Verification

Confirm each layer independently against the running service and the database.

# 1. Auth-bypass probe: an operator object where a string is expected must be
#    rejected with 400, never 200 with a session.
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3000/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":{"$ne":null}}'
# Expected: 400

# 2. $where probe against a search endpoint must fail.
curl -s -X POST http://localhost:3000/search \
  -H 'Content-Type: application/json' \
  -d '{"filter":{"$where":"sleep(2000)||true"}}'
# Expected: 400 (app layer) or a server error — never a 2s delay then results

# 3. Confirm server-side JavaScript is disabled at the database.
mongosh --quiet --eval 'db.eval ? "JS ENABLED - FIX" : "ok"' 2>&1 | tail -1
mongosh --quiet --eval 'try { db.getSiblingDB("test").find({$where:"true"}).toArray(); print("JS ENABLED - FIX") } catch(e){ print("ok: " + e.codeName) }'
# Expected: ok / JSInterpreterFailure or similar

A correct deployment returns 400 for both injection probes and reports JavaScript as disabled. If the $ne probe ever returns a valid session, the type-coercion layer is missing or bypassed — treat it as a live authentication bypass.


Troubleshooting

Symptom Likely cause Fix
$ne login probe returns 200 with a session Credential field is not type-coerced; filter matches a hash with an operator Add a strict schema that forces the password/username field to z.string(); never match the hash inside the query
express-mongo-sanitize throws Cannot set property query of #<IncomingMessage> Newer Express makes req.query a read-only getter; the middleware mutates it in place Use the onSanitize hook without in-place mutation, or move sanitization to a body-only pass and rely on schema validation
$where payload causes multi-second response delays Server-side JavaScript still enabled on mongod Set security.javascriptEnabled: false and restart; block $where in the app allowlist as well
Mongoose query with { $gt: ... } from input still executes Field typed as Schema.Types.Mixed, or sanitizeFilter disabled Give the field a concrete schema type; enable mongoose.set('sanitizeFilter', true)
Regex search endpoint intermittently pins CPU User-supplied $regex pattern enabling ReDoS Escape the literal, anchor and length-bound the pattern, or replace with an Atlas Search text index

Common Implementation Mistakes


Frequently Asked Questions

Does Mongoose protect me from NoSQL injection automatically?

Partially. Mongoose casts values against your schema for typed fields, so sending {"$ne": null} to a String path throws a CastError before the query runs. But that protection evaporates for Schema.Types.Mixed fields, when sanitizeFilter is turned off, and for aggregation pipelines whose stages you build from raw input. Schema casting is a useful layer, but treat it as one control among several — pair it with boundary validation and typed query construction rather than relying on it alone.

Why is disabling $where important if I already validate input?

Because $where evaluates a JavaScript function inside the mongod process, so any single path that leaks user input into it becomes remote code execution or a trivial denial of service. Setting security.javascriptEnabled: false removes that entire operator class at the database, so even a future validation gap in one endpoint cannot be escalated to code execution. It is the highest-leverage defence-in-depth step available and costs nothing if you do not legitimately use server-side JavaScript.

Is express-mongo-sanitize enough on its own?

No. It strips keys containing $ and ., which stops the obvious operator-key injection, but it has two blind spots. It mutates req.query in place, which errors on Express versions where req.query is a read-only getter, and it does nothing about type confusion — a string field receiving an array, for instance, still changes query behaviour. Run it as a layer behind strict schema validation and type coercion, never as your primary control.