In production, MongoDB injection doesn't look like
; DROP TABLE— it looks like{ "$ne": null }in a JSON body that Express parses silently, bypassing your entire authentication layer with zero SQL and zero error messages.
A nineteen-line Express login route — the shape every Node.js codebase has three of — returns seven findings on the first npx eslint run: four errors, three warnings, all real, full output below. That is the concrete outcome of this guide, and getting there costs one install command plus four lines of config.
{ "$ne": null } is not a typo or a missing await. It's user-controlled request data flowing directly into a MongoDB query with no sanitization — operator injection, A03 Injection in OWASP's numbering, and a class generic linters can't see and code review consistently misses, because the type is any, the test posts a string, and the build stays green.
If you'd rather see findings on your own repo before reading the explanations, jump to setup — it's this, and then a config block:
npm install eslint-plugin-mongodb-security --save-dev
Otherwise: here are four specific bugs your team has almost certainly shipped — three the linter blocks outright, and one gap in the ruleset that review discipline still has to cover. I name the gap in Bug 4 rather than let you discover it in production.
{ "$ne": null } in a password field bypasses MongoDB authentication via operator injection (CWE-943, CVSS 9.8) — no SQL string, no error, full auth bypass with a known username and zero correct credentials.
Vulnerable:
// POST body: { "username": "admin", "password": { "$ne": null } }
await db.collection("users").findOne({
username: req.body.username,
password: req.body.password, // ← { "$ne": null } bypasses this one named user
});
Why it survived review: password: req.body.password is the obvious, correct-looking thing to write. Your tests posted a string, everything passed, and nothing in the diff signals that the value could stop being a string. Send { "username": "admin", "password": { "$ne": null } } instead and Express parses the body into a real JavaScript object; findOne then matches the named user whose password field is not null — true for every real account. Any known username logs in, no brute force required.
ESLint rule: no-unsafe-query, catching a CWE-943 injection (CVSS 9.8) on both username and password — one finding per tainted property. no-operator-injection is the sibling rule for the inverse shape: code that writes the dangerous operator explicitly in source, like password: { $ne: req.body.exclude }. Here the operator only exists in the attacker's JSON, not in your source, so no-unsafe-query's broader "tainted value reaches a query sink" check is the one that fires — confirmed by running both rules against this exact snippet. One scope caveat, and it's a real one: the check is direct-expression, not full inter-procedural taint tracking — const pwd = req.body.password; findOne({ password: pwd }) one hop removed can still slip past today. That is a false negative by design, not by accident: the rule trades that hop for near-zero noise on the direct shape, which is the shape that actually ships.
Fix:
// ✅ Hash and compare separately — never query by raw password
const user = await db.collection("users").findOne({
username: { $eq: req.body.username },
});
if (!user) return res.status(401).json({ error: "Invalid credentials" });
const valid = await bcrypt.compare(req.body.password, user.passwordHash);
if (!valid) return res.status(401).json({ error: "Invalid credentials" });
One correction worth being explicit about, because it's a common misconception: switching to Mongoose (User.findOne instead of the raw driver) does not fix this on its own. I checked — Mongoose's schema-level casting only coerces an operator's operand, not the operator itself; findOne({ username: { $ne: null } }) against a String-typed field passes straight through as { username: { $ne: null } } on current Mongoose (verified on 9.7.3, strictQuery on or off), because $ne is a valid query operator and null needs no casting. The ODM layer is not a NoSQL-injection boundary. What actually closes the hole is the explicit $eq wrap no-unsafe-query's own suggested fix uses, plus never gating auth on anything but bcrypt.compare against the hash — the query only has to find the account, not verify the password.
$where runs arbitrary server-side JavaScript inside the MongoDB process — reaching it with any user-controlled string, hand-written or smuggled through populate(), is remote code execution (CWE-943, CVSS 9.0 as the rule ships it), not a filter bug.
Vulnerable:
db.collection("orders").find({
$where: `this.total > ${req.query.minTotal}`,
});
Why it survived review: Template literal interpolation looks like ordinary string formatting. Reviewers see it as building a filter, not executing user-supplied JavaScript on the database server. But $where runs server-side JS — this is a direct code execution primitive, whether a developer types it by hand (as here) or an attacker smuggles it in through a filter the app never meant to expose. That second path is exactly what CVE-2024-53900 and its bypass CVE-2025-23061 are: Mongoose's populate({ match: req.body.filter }) forwarded attacker-controlled filters into its internal sift() evaluator, which honors $where (and, after the first patch, a $or-nested $where) and executes it in the app process. Different entry point, same lesson — $where reaching user input in any form is the vulnerability, not just the hand-written version shown here.
The populate() path is exactly what no-unsafe-populate exists for — it fires the same way no-unsafe-where does here, just on the match option instead of a direct query:
// Vulnerable: req.body.filter reaches populate()'s match option
Order.find({ userId: req.user.id }).populate({
path: "items",
match: req.body.filter, // ← forwarded into sift(), which honors $where
});
4:3 error CWE-943 no-unsafe-populate (req.body.filter reaches populate().match, line 4 of this snippet)
One patch closed the original $where path; the bypass reopened it through a $or-nested variant — which is why the rule flags the match option itself rather than trying to enumerate every operator shape that reaches sift().
ESLint rule: no-unsafe-where + no-unsafe-query — both CWE-943, but they print different scores on the same line of code: no-unsafe-where emits 9.0, no-unsafe-query emits 9.8 (rule metadata read at v8.2.5, 2026-07-27). Take the higher one as the triage number; the two rules are scoring different things — a sink that should never exist versus tainted data reaching it.
Fix:
// ✅ Use typed query operators instead of $where
db.collection("orders").find({
total: { $gt: Number(req.query.minTotal) },
});
If your cluster runs with security.javascriptEnabled: false (the default on MongoDB Atlas; opt-in on self-managed deployments), this exact payload fails loudly with a server-side error instead of executing silently — but the linter still catches it at author time, before you find out which setting your production cluster actually has.
An unprojected .find() returns every field in the document — not just the ones the caller asked for — which means a schema change six months from now can turn a "clean, simple query" into a data leak (CWE-200) without anyone touching this line again.
Vulnerable:
// Returns every field including passwordHash, ssn, tokens
const users = await User.find({ role: "member" });
Why it survived review: No projection looks like a clean, simple query. Reviewers rarely think about what fields the document contains — they're focused on the filter logic. This is the one bug in the article that isn't injection at all: nobody sent a malicious payload, the query is exactly what the developer intended, and it's still a leak. That's also why it's the easiest to miss twice. A schema evolves the way schemas do: ssn gets added for a KYC feature, passwordResetToken gets added six sprints later for a "forgot password" flow, both land on the same User model this .find({ role: "member" }) already queries — and the unprojected call three files away starts returning both fields to whatever serializer or API response touches users next, with no diff on this line to review. The bug isn't in the code that changed; it's in the code that didn't.
ESLint rule: no-select-sensitive-fields + no-unbounded-find (CWE-200, CWE-400)
Fix:
// ✅ Mongoose: explicit projection + a bound — clears both rules
const users = await User.find({ role: "member" })
.select("name email -_id")
.limit(100);
Or, on the native driver, where the projection lives under a projection key:
// ✅ Same two guarantees, driver syntax
const users = await db
.collection("users")
.find(
{ role: "member" },
{ projection: { name: 1, email: 1, _id: 0 }, limit: 100 },
)
.toArray();
The -_id isn't decoration — MongoDB includes _id in the result by default even with an otherwise-selective projection, so it's the one field you have to exclude explicitly if the caller shouldn't see it.
The shape matters more than it should, and this is a rough edge in my own rule rather than in MongoDB: no-select-sensitive-fields recognizes a .select() chain, and it recognizes the native driver's { projection: { … } } options object — but it does not recognize Mongoose's bare second-argument form, User.find(filter, { name: 1, email: 1 }). That form is a perfectly valid projection at runtime and closes the leak; the rule still warns on it. A false positive on safe code, in other words, and the reason the Mongoose example above uses .select(). If you hit that warning on a query you know is projected, that's why — and it's on my list, not on yours.
Passing req.body straight into an .aggregate() pipeline stage lets an attacker reshape the filter itself — erasing scoping, abusing operators, or triggering regex backtracking (CWE-400) — and it's the one bug in this article the plugin doesn't catch yet.
Vulnerable:
const pipeline = [
{ $match: { category: req.query.category } },
{ $group: { _id: "$userId", total: { $sum: "$amount" } } },
{ $match: req.body.additionalFilter }, // ← attacker controls pipeline stage
];
await db.collection("transactions").aggregate(pipeline);
Why it survived review: The first $match looks safe (it's a string field). The second one — accepting the entire filter body of a $match stage from the request body — looks like "flexible filtering" in a code review. It isn't. req.body.additionalFilter becomes the filter document passed to $match, not a new pipeline stage — so an attacker can't smuggle in a $lookup this way ($match only accepts query operators, not stage names), but they can send {} to erase the intended category scoping and match every transaction, { "$expr": { "$gt": ["$total", 0] } }-style operator abuse to reshape the filter arbitrarily, or an unbounded { "field": { "$regex": "^(a+)+$" } } to trigger catastrophic regex backtracking (CWE-400) on every document in the collection. The blast radius is the same as any unconstrained filter object: the attacker controls what "matches," not what MongoDB executes.
ESLint rule: none of the 16, honestly. no-unsafe-query (CWE-943) checks the first argument to .find()/.aggregate()/etc. for an inline object literal with a tainted property — .aggregate(pipeline) passes an array built earlier, so the rule's ObjectExpression-only check never runs on it, and it wouldn't fire even inlined, since aggregate()'s first argument is always an array, never the object the rule expects. I checked this against the shipped rule source (v8.2.5) rather than assume: it's a known false negative with a named cause, not a "should be obvious to the linter" claim. Writing the plugin and writing the article that grades it is a conflict of interest, so the useful thing I can do is tell you where my own tool loses — this is the one bug in four the plugin doesn't catch, which is exactly why review discipline on .aggregate() calls still matters after you install it.
Fix:
// ✅ Allowlist the fields you accept; reject rather than silently fall through
const allowedCategories = ["food", "travel", "tech"];
if (!allowedCategories.includes(req.query.category)) {
return res.status(400).json({ error: "Invalid category" });
}
const pipeline = [
{ $match: { category: req.query.category } },
{ $group: { _id: "$userId", total: { $sum: "$amount" } } },
];
The allowlist check has to be a guard, not a ternary with a null fallback — category: null still matches every document where category is null or missing, which quietly widens the query to "everyone's transactions" instead of rejecting the request. Reject early; don't substitute a default that's also a valid filter value.
This allowlist closes the immediate injection, but it also removes dynamic filtering entirely. If the product actually needs caller-supplied filters at this API boundary, the upgrade path is a structured schema (Joi or Zod) that validates the allowed operators and fields explicitly — that's still defense-in-depth alongside the linter, not a replacement for either the allowlist or the rule coverage this bug is missing.
eslint-plugin-mongodb-security is the only ESLint plugin built specifically for MongoDB/Mongoose codebases — 16 rules at v8.2.5 (published 2026-07-19), each mapped to a CWE and the relevant CVE. One config block catches Bugs 1 through 3 above:
npm install eslint-plugin-mongodb-security --save-dev
// eslint.config.mjs
import mongodbSecurity from "eslint-plugin-mongodb-security";
export default [mongodbSecurity.configs.recommended];
That's the whole setup. The preset registers the plugin under the mongodb-security namespace and sets all 16 severities itself, so there's no plugins: block to write and no rule list to maintain by hand. It ships as flat config, so you want ESLint 9 or newer — on a legacy .eslintrc project, migrate the config file first; that's the only prerequisite here.
Run npx eslint . and findings appear at the exact vulnerable line, tagged with CWE number and a fix suggestion. The recommended preset fires the NoSQL-injection rules as errors (CI-blocking) and the data-exposure rules as warnings. Use configs.strict to promote all 16 to errors. configs.mongoose is the ODM-focused preset: it promotes require-schema-validation, no-bypass-middleware, and no-select-sensitive-fields from warnings to errors — but it enables 9 rules, not 16, dropping the credential and connection rules on the assumption that a Mongoose app configures its connection elsewhere. Compose it alongside recommended rather than instead of it, unless you've checked that assumption holds in your repo.
Ran the recommended preset over this 19-line Express login route. Paste it into a file and reproduce the output below rather than take my word for it — that's the fastest way to confirm the install actually works:
const { MongoClient } = require("mongodb");
const client = new MongoClient(
"mongodb://admin:supersecret@prod-cluster.example.com:27017/app",
);
async function login(req, res) {
const db = client.db("app");
const user = await db.collection("users").findOne({
username: req.body.username,
password: req.body.password,
});
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
const orders = await db
.collection("orders")
.find({
$where: `this.userId == '${user._id}'`,
})
.toArray();
return res.json({ user, orders });
}
module.exports = { login };
Running npx eslint login-route.js against that exact file gives 7 real findings, line and column numbers included. One unedited line, exactly as ESLint prints it:
7:5 error Tainted value reaches MongoDB query sink (CWE-943) mongodb-security/no-unsafe-query
The table below is the same 7 findings reformatted for scan-ability — real output prefixes every rule ID with mongodb-security/ and puts the CWE inside the message string, not in its own column. The parentheticals are my annotations, added after the run, not tool output:
2:32 error CWE-798 no-hardcoded-connection-string
6:22 warning CWE-200 no-select-sensitive-fields (the users.findOne — no projection, could return passwordHash)
7:5 error CWE-943 no-unsafe-query (req.body.username)
8:5 error CWE-943 no-unsafe-query (req.body.password)
13:24 warning CWE-200 no-select-sensitive-fields (the orders.find — no projection)
13:24 warning CWE-400 no-unbounded-find (the same orders.find — no .limit())
14:5 error CWE-943 no-unsafe-where ($where — fires on the key alone, see below)
✖ 7 problems (4 errors, 3 warnings)
Seven findings, three call sites: the users.findOne (lines 6-8) produces one warning and two errors, one per tainted property; the orders.find() (lines 13-14) produces two warnings — no-select-sensitive-fields and no-unbounded-find are independent rules firing on one unguarded call, not double-counting — plus the $where error; and the line 2 connection string stands alone. The four errors are what a CI gate should block on merge; the three warnings are the data-exposure class recommended flags without blocking.
One honest caveat on that last error: in this snippet user._id came back from the database, not from req.*, so this specific finding isn't operator-injection in the way Bug 2's req.query.minTotal version is — no-unsafe-where is still right to fire on it unconditionally, for the reason covered in the rule table below (any $where key, tainted or not, is a code-execution primitive).
The 8 injection and credential rules below run at near-zero false-positive rate in practice — high precision, deliberately bought with the recall gap Bug 1 and Bug 4 describe. They get there for two different reasons: no-unsafe-query and no-operator-injection trace the flagged value back to a req.*-shaped source before firing (direct-expression only — see the scope caveat in Bug 1), while no-unsafe-where and the credential rules fire on the sink pattern itself (any $where key, any literal connection string) regardless of where the interpolated value came from — because there's no safe use of either pattern to begin with, tainted or not.
| Rule | Severity | CWE | CVSS |
|---|---|---|---|
no-unsafe-query | error | CWE-943 | 9.8 |
no-operator-injection | error | CWE-943 | 9.1 |
no-unsafe-where | error | CWE-943 | 9.0 |
no-hardcoded-connection-string | error | CWE-798 | 7.5 |
no-hardcoded-credentials | error | CWE-798 | 7.5 |
no-unsafe-regex-query | error | CWE-400 | 7.5 |
no-unsafe-populate | error | CWE-943 | 6.5 |
no-debug-mode-production | error | CWE-489 | 3.1 |
require-tls-connection | warn | CWE-295 | 7.4 |
require-auth-mechanism | warn | CWE-287 | 6.5 |
require-schema-validation | warn | CWE-20 | 6.1 |
no-select-sensitive-fields | warn | CWE-200 | 5.3 |
no-bypass-middleware | warn | CWE-284 | 5.3 |
no-unbounded-find | warn | CWE-400 | 4.3 |
require-projection | off | CWE-200 | 3.7 |
require-lean-queries | off | CWE-400 | 4.3 |
Severity, CWE, and CVSS columns are read straight out of the shipped rule metadata at v8.2.5, checked 2026-07-27 — not from the docs site, which is a separate artifact and can drift. strict promotes all 16 to error. require-projection and require-lean-queries are off by default (perf hygiene, not security) — turn them on explicitly. no-select-sensitive-fields and require-projection both carry CWE-200 but check different things: no-select-sensitive-fields fires on any .find()/.findOne()/.findById() that lacks a projection provably excluding a default sensitive-field list (password, refreshToken, apiKey, secret) — an unprojected call is unsafe by default, since it can't prove those fields are excluded — while require-projection (off by default) is the blunter rule that flags any missing projection at all, regardless of field sensitivity. That's why Bug 3's User.find({ role: "member" }) triggers the former as a warning even though role itself isn't sensitive — the query has no projection, so the rule can't rule out passwordHash coming back too.
One row in that table is wrong, and it's mine. require-tls-connection ships tagged CWE-295 (Improper Certificate Validation), but the rule flags the absence of TLS entirely, not a misconfigured certificate check on a connection that already has one — the behavior it describes is CWE-319, Cleartext Transmission of Sensitive Information. I wrote that rule, I published an audit of exactly this failure mode (33 of our 203 rules mislabel their own severity), and then I shipped one more. The table above prints what the plugin actually emits rather than what I wish it emitted, because a rules table you can't diff against npx eslint output is decoration.
The two credential rows (no-hardcoded-connection-string, no-hardcoded-credentials) are the same CWE-798 class covered in depth in My credential rule reported 842 secrets in vercel/ai. The real count was 0. — including why naive secret-detection regexes fire on TypeScript union types. The two off-by-default rules are also the noisiest: they flag missing performance hygiene on every query, not a specific exploit, so they're opt-in rather than shipped as warnings.
"We already run express-mongo-sanitize." That's the first objection any senior MongoDB dev will raise, and it's a fair one — so it's worth being specific about where that middleware stops. express-mongo-sanitize strips $-prefixed keys from req.body/req.query/req.params on routes where it's mounted. It does nothing for Bug 2's $where template literal (the payload isn't a $-prefixed key, it's a string interpolated into one), nothing for Bug 4's raw .aggregate() pipeline (many teams only mount sanitizer middleware on top-level route bodies, not on every field that eventually reaches an aggregation stage), and nothing for Bug 3's projection leak (there's no operator to strip — the bug is what you didn't ask for, not what the attacker sent). Mongoose's schema-level query casting doesn't help here either — as Bug 1 shows, casting coerces an operator's operand, not the operator itself, so $ne/$gt/etc. sail through a typed field on the ODM exactly as they would on the raw driver. The linter beats both middleware and the ODM's non-existent injection boundary on Bugs 1 through 3 because it runs at author time, on every code path, including the one an AI assistant just pasted in — before either runtime defense is even in the request path. Bug 4 is the honest exception: neither the sanitizer, Mongoose, nor (currently) the linter catches it, which is the strongest argument for why .aggregate() calls still need a human in the loop.
I ran eslint-plugin-security against Bug 1's vulnerable snippet directly: zero findings. Not a near-miss — nothing fires on password: req.body.password reaching findOne(), because the rule set has no concept of a MongoDB query sink. Generic security linters (eslint-plugin-security, eslint-plugin-sonarjs) don't know the MongoDB query API. They can't distinguish db.collection("users").find({ $where: userInput }) from console.log({ $where: "debug" }). This is the gap that makes linting a different tool class from a SAST scan rather than a cheaper version of one: the sink list is the product. I benchmarked 17 security-adjacent ESLint plugins head-to-head against a labelled ground-truth corpus in The #1 ESLint Security Plugin Just Got Fixed. It Still Catches 0 of 6 SQL Injections. — eslint-plugin-security misses every MongoDB-specific finding across the full corpus, not just this one.
The MongoDB-specific plugin knows which methods are query execution points (.find(), .findOne(), .aggregate(), .updateMany()), which operators are dangerous ($where, $expr, $function, $accumulator), and what constitutes user input in the MongoDB context.
MongoDB operator injection, $where RCE, projection leaks, and pipeline injection are not hypotheticals — they are the shapes AI assistants hand back when asked for "an Express login route with MongoDB." I benchmarked 700 AI-generated functions across 5 models (snapshot dated 2026-02-09) — see Aggregate Benchmarks Lie. Here's What 700 AI Functions Look Like by Security Domain. for the full per-domain breakdown — and sliced to the database layer, filtered to the functions that made a database call:
| Model (database tasks) | Vulnerability rate |
|---|---|
| Claude Haiku 4.5 | 39% |
| Claude Opus 4.6 | 61% |
| Claude Sonnet 4.5 | 71% |
| Gemini 2.5 Flash | 75% |
| Gemini 2.5 Pro | 96% |
Two honest boundaries on that table before you quote it. First, it's the database-task slice only (the subset of the 700 that made a DB call), not the models' overall security scores — Opus 4.6's 61% here is database-layer specific and says nothing about its standing on auth or injection classes outside this slice. Second, and more relevant to a MongoDB article: the database prompts in that run targeted PostgreSQL, not MongoDB. What transfers is the class — a model that interpolates user input into a query sink does it the same way whether the sink is $1 or $where — not the exact percentage. Treat 96% as the base rate for AI-written data-layer code in general, and run the linter to get your own.
Read as a slice, the ordering is still counterintuitive: Claude Haiku 4.5 at 39% beats both larger Claude models on this task class, which is the kind of inversion worth checking your own assumptions against before picking a model by parameter count alone.
The flagship Gemini model shipped a database-layer vulnerability in 96% of runs. The twist: that same model fixed 93% of its database bugs the moment it was handed the CWE-tagged violation from the linter — the same shape no-unsafe-query (CWE-943) emits. The model writes the bug; the linter names it in the model's own language; the model fixes it on the next turn. The SQL-side counterpart shows the same flagship model at the same failure rate: Gemini 2.5 Pro Wrote Unsafe SQL 96% of the Time — 3 Node.js Injection Patterns.
That's why the rule lives in the linter and not in a wiki page. A static rule is the only reviewer that runs on every save, every paste, every AI completion — and it doesn't get tired on the 40th login route.
For the onboarding workflow that pairs this with a broader static-analysis protocol, see The 30-Minute Security Audit: 140 Gemini-Written Functions, 102 Shipped Vulnerable.
{ "$ne": null } is a valid password against every MongoDB auth handler above. The linter is the one reviewer that rejects it on every save, every paste, and every AI completion — including the three of four shapes above, and honestly excluding the fourth.
Point it at your repo and find out which ones you have:
npm install eslint-plugin-mongodb-security --save-dev
npx eslint .
Three of the four bugs in this article stop at that command. The fourth — the .aggregate() pipeline — stops at a human reading the diff, until I close the gap. When I do, it'll be because someone ran the plugin on a real pipeline and told me what it missed: which shape did it miss in your codebase? Open an issue with the snippet, or leave it in the comments and I'll pick it up from there.
Next in The Hardened Stack — the credential rules in the table above are the same class that produced my worst false-positive result to date: My credential rule reported 842 secrets in vercel/ai. The real count was 0. — how entropy-based secret detection goes wrong, and what replaced it. The SQL-side counterpart, if you have both databases: Your node-postgres Data Layer Fails 4 Ways in Production.
Also relevant:
- I Let Claude Write 80 Functions. 65-75% Had Security Vulnerabilities.
- We Ranked 5 AI Models by Security. The Leaderboard Is Wrong.
- I Asked Claude to Fix Its Own Security Bugs. 1 in 3 Fixes Added a NEW Vulnerability.
eslint-plugin-mongodb-security is part of the Interlace ESLint ecosystem. Source on GitHub · Follow: Dev.to/ofri-peretz
