NoSQL injection in Express and MongoDB: the login bypass and how to fix it
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.
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:
$whereruns a JavaScript expression inside MongoDB. MongoDB's FAQ lists$where,mapReduce,$accumulatorand$functionas operations that run arbitrary JavaScript, and tells you to take care. PortSwigger shows attackers pulling a password out one character at a time this way.$regexlets an attacker probe values by pattern, again one character at a time. A regex you let users supply can also be slow to run, so it's a way to burn your database's CPU.
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.
| Operator | What it does to you | Fix |
|---|---|---|
| $ne, $in, $gt | Matches any user and skips the password check | Validate as string |
| $where | Runs JavaScript inside MongoDB | Never use user input |
| $regex | Probes values or runs slow patterns | Escape 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.
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 })
})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 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 belowThe 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.
Validate every input as a string
Parse
req.bodywith a schema where each field isz.string()with a max length. Return 400 when parsing fails. An object where a string belongs never reaches MongoDB.Keep the password out of the filter
Find the user by email only, then compare with
bcrypt.compareagainst the stored hash. A login that putspasswordinfindOne()is wrong even without injection.Add express-mongo-sanitize
On Express 4, call
app.use(mongoSanitize())afterexpress.json()and before your routes. It strips$and.keys from the request. On Express 5, use the manualsanitizecalls from the warning above.Turn on Mongoose sanitizeFilter
Call
mongoose.set("sanitizeFilter", true)once at startup. Wrap operators you write yourself withmongoose.trusted().Tame $where and $regex
Never put user input into
$where. If you run your ownmongod, MongoDB's FAQ says you can disable server-side JavaScript withsecurity.javascriptEnabled: falseor--noscripting. For search, escape the text before it becomes a pattern. Wrap the operator inmongoose.trusted()too, because step 4 turned onsanitizeFilterand would otherwise treat your own$regexas 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.
Slow down guessing
Add login rate limiting. It caps how fast anyone can probe your login route.
Test it yourself
- Start the app locally with one test user, for example ana@example.com.
- 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.
- Try one operator at a time. Send your real test email with
{"$ne":""}as the password. A 200 means the filter still takes operators. - 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. - 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. - 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.
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
- PortSwigger Web Security Academy: NoSQL injection
- MongoDB manual: How does MongoDB address SQL or Query injection?
- express-mongo-sanitize on GitHub
- Mongoose API docs: sanitizeFilter(), set() and trusted()
- 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 repoLeakcade 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