LEAKCADE

NoSQL injection in Express and MongoDB: the login bypass and how to fix it

Updated 8 min read

NoSQL injection happens when your Express route passes req.body straight into findOne() or find(). An attacker sends {"$ne": null} where you expected a string, and MongoDB treats it as a query operator ($ne means "not equal"). On a login route, that logs them in without a password. Check that every input is a string, then add express-mongo-sanitize and Mongoose's sanitizeFilter as safety nets.

QUICK CHECKLIST

  • Validate every req.body field as a string before any query
  • Never put the password inside the findOne filter
  • Add express-mongo-sanitize after express.json()
  • Turn on mongoose.set('sanitizeFilter', true)
  • Escape user input before it goes into $regex
  • Never build $where from user input

What NoSQL injection is and why Mongoose apps get it

NoSQL injection is when an attacker interferes with the queries your app sends to a NoSQL database. PortSwigger lists what that can lead to: bypassing authentication, extracting or editing data, denial of service, and in some setups running code on the server.

MongoDB's own FAQ says traditional SQL injection isn't a problem because a client builds a BSON object (MongoDB's binary JSON format) instead of gluing a string together. The catch is that Express turns the JSON body into a JavaScript object for you, so an attacker who sends an object where you expected a password makes req.body.password an object.

How the $ne login bypass travelsAn attacker sends a JSON login body where the password is an object with a $ne operator. Express passes it straight into findOne, MongoDB matches the account for that email whatever the password is, and the attacker is logged in without a password.ATTACKERSends password as{"$ne": null}EXPRESSfindOne({ email,password })MONGODBOperator matchesany passwordRESULTLogged in, nopassword neededATTACKERSends password as {"$ne": null}EXPRESSfindOne({ email, password })MONGODBOperator matches any passwordRESULTLogged in, no password needed
The password field carries an operator object instead of a string.

AI tools write User.findOne({ email, password }) constantly because it's short and it works in the demo. Nothing in that line checks that password is a string. If you're wondering how much to trust generated code in general, read is AI-generated code secure.

How a NoSQL injection login bypass works

A normal login request looks like this:

{"email":"ana@example.com","password":"hunter2"}

The attacker sends this instead:

{"email":{"$ne":null},"password":{"$ne":null}}

$ne means "not equal", so your filter now reads "any user whose email isn't null and whose password isn't null". MongoDB returns the first match, so PortSwigger notes you're logged in as the first user in the collection, which could be an admin depending on your data.

To target one account, the attacker uses a guessed email and {"$ne": ""} for the password. PortSwigger's example uses $in with a list of likely names such as admin and superadmin.

The same trick works from a URL. username[$ne]=invalid can turn into an object during query parsing, depending on your Express settings. So search and filter routes that read req.query are exposed too.

Two more operators do damage beyond login:

It's the same weakness as in SQL: untrusted input changes the meaning of the query. If you use an ORM, the SQL injection ORM guide covers the relational version.

OperatorWhat it does to youFix
$ne, $in, $gtMatches any user and skips the password checkValidate as string
$whereRuns JavaScript inside MongoDBNever use user input
$regexProbes values or runs slow patternsEscape the text

Three operators attackers reach for first.

Login bypass: before and after

This is the shape AI tools produce most often, next to a fixed version.

BEFOREPasses req.body into the filter
import express from "express"
import { User } from "./models/user"

const app = express()
app.use(express.json())

app.post("/login", async (req, res) => {
  const { email, password } = req.body
  const user = await User.findOne({ email, password })
  if (!user) return res.status(401).json({ error: "Invalid login" })
  res.json({ id: user.id })
})
AFTERChecks types, compares a hash
import express from "express"
import bcrypt from "bcryptjs"
import { z } from "zod"
import { User } from "./models/user"

const app = express()
app.use(express.json())

// Any valid bcrypt hash. Generate your own once.
const DUMMY_HASH = "$2a$10$CwTycUXWue0Thq9StjUM0uJ8DQ1Z9b5o8tqF1rQyH7S1x6u0Kxk6e"

const LoginBody = z.object({
  email: z.string().min(3).max(254),
  password: z.string().min(1).max(200),
})

app.post("/login", async (req, res) => {
  const parsed = LoginBody.safeParse(req.body)
  if (!parsed.success) return res.status(400).json({ error: "Bad request" })
  const { email, password } = parsed.data

  const user = await User.findOne({ email }).select("+passwordHash")
  const ok = await bcrypt.compare(password, user ? user.passwordHash : DUMMY_HASH)
  if (!user || !ok) return res.status(401).json({ error: "Invalid login" })
  res.json({ id: user.id })
})
The example returns an id to stay short. Issue a real session in your app. The dummy hash keeps response time similar when the email is unknown.

The fixed handler rejects anything that isn't a string before MongoDB sees it. It also keeps the password out of the filter, so no operator can match on it. For more on schema checks, see input validation in API routes.

express-mongo-sanitize and Mongoose sanitizeFilter

Validation is the fix. These two libraries are a backup, so one forgotten route doesn't become a hole. OWASP says positive server-side validation is not a complete defense on its own, which is a good reason to layer them.

express-mongo-sanitize is Express middleware (code that runs on every request before your routes). Per its README, it searches req.body, req.query, req.params and req.headers for keys that start with $ or contain a . and removes them, or swaps the characters if you set replaceWith. Add it after the body parser and before your routes.

import express from "express"
import mongoose from "mongoose"
import mongoSanitize from "express-mongo-sanitize"

mongoose.set("sanitizeFilter", true)

const app = express()
app.use(express.json())
app.use(mongoSanitize()) // Express 4 only, see the warning below

The sanitizeFilter option is Mongoose's own layer. With it on, Mongoose wraps nested objects whose keys start with $ in $eq. So {"$ne": null} is searched as a literal value and matches nothing. If you write operators yourself, like { age: { $gt: 18 } }, mark them with mongoose.trusted().

It only applies to query filters. It does not touch aggregate() pipelines, and it won't stop a top-level $where key if you pass the whole req.body as the filter. Validation still has to cover those routes.

How to fix NoSQL injection, step by step

Do these in order. The first two close the login bypass on their own.

  1. Validate every input as a string

    Parse req.body with a schema where each field is z.string() with a max length. Return 400 when parsing fails. An object where a string belongs never reaches MongoDB.

  2. Keep the password out of the filter

    Find the user by email only, then compare with bcrypt.compare against the stored hash. A login that puts password in findOne() is wrong even without injection.

  3. Add express-mongo-sanitize

    On Express 4, call app.use(mongoSanitize()) after express.json() and before your routes. It strips $ and . keys from the request. On Express 5, use the manual sanitize calls from the warning above.

  4. Turn on Mongoose sanitizeFilter

    Call mongoose.set("sanitizeFilter", true) once at startup. Wrap operators you write yourself with mongoose.trusted().

  5. Tame $where and $regex

    Never put user input into $where. If you run your own mongod, MongoDB's FAQ says you can disable server-side JavaScript with security.javascriptEnabled: false or --noscripting. For search, escape the text before it becomes a pattern. Wrap the operator in mongoose.trusted() too, because step 4 turned on sanitizeFilter and would otherwise treat your own $regex as a literal value:

    const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
    
    const q = z.string().max(50).parse(req.query.q) // throws on objects
    const items = await Product.find({
      title: mongoose.trusted({ $regex: escapeRegex(q), $options: "i" }), // trusted: needed with sanitizeFilter on
    })

    Catch the parse error and return 400.

  6. Slow down guessing

    Add login rate limiting. It caps how fast anyone can probe your login route.

Test it yourself

  1. Start the app locally with one test user, for example ana@example.com.
  2. Send the operator payload:
curl -i -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email":{"$ne":null},"password":{"$ne":null}}'

Bad result: a 200 with a user id or a session cookie. Good result: 400 from validation, or 401.

  1. Try one operator at a time. Send your real test email with {"$ne":""} as the password. A 200 means the filter still takes operators.
  2. Check routes that read req.query: curl -g 'http://localhost:3000/items?q[$ne]=x'. This only exercises operator parsing if your app uses the extended (qs) query parser, which is the Express 4 default. Bad: results that would not match a literal search come back. Good: 400.
  3. If a route passes the whole body as the filter, add "$where":"0" to one request and "$where":"1" to another. PortSwigger says a difference between the responses suggests your JavaScript expression is being evaluated.
  4. Search your code for risky calls:
grep -rnE "(find|findOne|updateOne|deleteOne|countDocuments)\([[:space:]]*req\.(body|query|params)" src
grep -rn "\$where" src

These only catch the direct form. Also review every handler that destructures from req.body or req.query and passes those values into a filter. The grep won't find findOne({ email, password }).

To see what the sanitizer would strip without changing anything, set dryRun: true and an onSanitize callback in mongoSanitize(). Both options are in the README. Every log line is a route that was passing operators through.

Fix prompt you can paste

Give this to Cursor or Claude Code. It lists findings with file and line first and waits for your go-ahead, so you can check them before it edits anything.

FIX PROMPT
Audit this Express + Mongoose codebase for NoSQL injection.

1. Find every call to find, findOne, findOneAndUpdate, updateOne,
   updateMany, deleteOne, deleteMany, countDocuments and aggregate where
   the filter contains req.body, req.query, req.params or values
   destructured from them. Include login, password reset, search and
   get-by-id routes. Also find any $where, $regex, $function or mapReduce
   built from user input. List each finding as file:line, the code on that
   line, and which input reaches it. Stop after this list and wait for my
   go-ahead before editing anything.
2. Fix each one: validate the input with zod so fields are z.string() with
   max lengths (reject objects with a 400) before any query. Never put the
   password in the filter. Look up by email, then compare with bcrypt.
   Escape user text before using it in $regex. Remove user input from
   $where.
3. Call mongoose.set("sanitizeFilter", true). It does not cover aggregate
   pipelines, so validate those inputs explicitly. Use mongoose.trusted()
   only for operators that we write ourselves. If the app is on Express 4,
   add express-mongo-sanitize after express.json(). If it is on Express 5,
   do not use the middleware: call mongoSanitize.sanitize(req.body) and
   mongoSanitize.sanitize(req.params) in the routes instead.
4. Add a test that POSTs {"email":{"$ne":null},"password":{"$ne":null}}
   to /login and expects 400 or 401.
5. Explain every change in plain English. Do not change unrelated code.

For the rest of your app, work through the vibe coding security checklist. To scan the repo afterwards, run it through Leakcade.

Further reading

  1. PortSwigger Web Security Academy: NoSQL injection
  2. MongoDB manual: How does MongoDB address SQL or Query injection?
  3. express-mongo-sanitize on GitHub
  4. Mongoose API docs: sanitizeFilter(), set() and trusted()
  5. OWASP Top 10:2025 A05 Injection

FAQ

Does Mongoose protect against NoSQL injection by default?

No. A schema doesn't stop an operator object like {"$ne": null} from reaching your filter. Validate every input as a string, and turn on the sanitizeFilter option as a second layer.

What does express-mongo-sanitize do?

It's Express middleware that searches req.body, req.query, req.params and req.headers for keys that start with $ or contain a .. By default it removes them. You can set replaceWith to swap the characters instead. Its README describes it as Express 4.x middleware.

Isn't MongoDB safe from injection because queries are BSON objects?

Only from the string-concatenation kind. MongoDB's FAQ says traditional SQL injection isn't a problem because clients build BSON objects. If you pass an attacker-controlled object into a filter, they can still add operators. $where and similar operations also run JavaScript, so never build them from user input, and disable server-side JavaScript on your own mongod if you don't need it.

What is mongoose sanitizeFilter?

It's a Mongoose option that wraps nested objects with $ keys in $eq, so an injected operator is treated as a literal value. Operators you write yourself, like { $gt: 18 }, need mongoose.trusted(), or Mongoose treats them as literal values too.

Do I still need validation if I use a sanitizer?

Yes. A sanitizer only removes keys that start with $ or contain a dot, so an array or a number in a string field still gets through. Validation makes sure each field is the type you expect, and OWASP recommends positive server-side validation as part of preventing injection.

Want a second pair of eyes?

We review your whole repo and write a fix prompt for every finding. $19, report within an hour.

Check my repo

Leakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.