NoSQL Injection: MongoDB Doesn't Mean 'No Security' 🍃
NoSQL Injection: MongoDB Doesn't Mean "No Security" 🍃
A developer tells me: "I switched to MongoDB, so I don't need to worry about SQL injection anymore!"
Me: 😐
The attack surface just changed shape. It didn't disappear. Welcome to NoSQL injection — the SQL injection's equally dangerous cousin who never got the same press coverage but is absolutely at your production database right now, waiting patiently.
In my experience building production systems with Node.js backends and MongoDB, this is the vulnerability I see most often lurking in "modern" codebases. Teams migrate away from relational databases thinking they're also migrating away from injection risk. They are not.
What Even Is NoSQL Injection? 🤔
SQL injection works by sneaking SQL syntax into your queries. NoSQL injection works by sneaking query operators into your requests instead.
MongoDB uses JSON-like queries with special operators like $gt, $ne, $where. If user input reaches your query unsanitized, an attacker can inject those operators directly.
The dangerous code:
// Express route - classic mistake
app.post('/login', async (req, res) => {
const user = await User.findOne({
email: req.body.email,
password: req.body.password // 💀 Never do this
});
if (user) res.json({ token: generateToken(user) });
else res.status(401).json({ error: 'Invalid credentials' });
});
Looks fine, right? Here's what a hacker sends as the request body:
{
"email": "[email protected]",
"password": { "$ne": null }
}
That $ne is a MongoDB operator meaning "not equal to null". Every password in your database is not equal to null. The query becomes: find a user where email is [email protected] AND password is anything that exists.
Boom. Authentication bypassed. No SQL required. 🎩
Real Talk: I've Seen This in the Wild 💬
As someone passionate about security, I participate in a few responsible disclosure communities. I can tell you that NoSQL injection shows up constantly in Node.js/Express applications. The pattern is almost always the same: a developer who knew about SQL injection prevention, migrated to MongoDB, and assumed they were safe.
In security communities, we often discuss how the concept of injection hasn't changed in 30 years. Only the syntax changes. The root cause is always the same: trusting user input as query structure, not just query data.
The Three Flavors of NoSQL Injection 🍦
1. Operator Injection (Most Common)
This is what we saw above — injecting MongoDB operators via JSON body parsing.
// Attacker sends: { "username": { "$gt": "" } }
// Your query becomes: db.users.find({ username: { $gt: "" } })
// Matches EVERY user whose username is alphabetically greater than ""
// Which is... all of them.
2. JavaScript Injection via $where
MongoDB used to (and some versions still do) support $where with raw JavaScript strings. This is even worse.
// DON'T EVER DO THIS:
db.users.find({ $where: `this.username == '${username}'` });
// Attacker input: "' || '1'=='1
// Becomes: this.username == '' || '1'=='1'
// Always true. Everyone's data. Gone.
Pro Tip: Just... never use $where. Ever. There's always a better way.
3. Aggregation Pipeline Injection
Less common but worth knowing — malicious pipeline stages can be injected if you're building aggregations dynamically from user input. Same principle, different syntax.
The Safe Way: Input Validation Is Your Best Friend 🛡️
Never trust the shape of user input, only the content:
// Safe login handler
app.post('/login', async (req, res) => {
const { email, password } = req.body;
// Validate types FIRST — reject anything that isn't a string
if (typeof email !== 'string' || typeof password !== 'string') {
return res.status(400).json({ error: 'Invalid input' });
}
// Now query safely — operators can't hide inside a primitive string
const user = await User.findOne({ email });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
res.json({ token: generateToken(user) });
});
The key insight: { "$ne": null } is an object, not a string. A simple typeof check kills the attack instantly.
Using Mongoose? You're Safer, But Not Safe 🦺
Mongoose provides a schema layer that helps — if your schema says password: String, Mongoose will cast incoming values. An object might get stringified or rejected, depending on version.
But don't rely on this. Schema casting behavior has changed across Mongoose versions, and it's not a security guarantee. Explicit type validation is still your responsibility.
// Even with Mongoose, validate explicitly:
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1).max(128),
});
app.post('/login', async (req, res) => {
const result = loginSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: 'Invalid input' });
}
// Now you know email and password are definitely strings
const { email, password } = result.data;
// ... rest of logic
});
Zod (or Joi, or your validation library of choice) is the pattern I use in every production system I build. The schema is documentation, the validation is security.
Practical Checklist: Lock It Down 🔒
Before your MongoDB-backed app hits production:
- Validate input types — reject objects where strings are expected
- Never use
$wherewith user input — seriously, just don't - Never pass
req.bodyorreq.querydirectly into a Mongoose/MongoDB query - Use a validation library like Zod or Joi to enforce schemas at the API boundary
- Disable MongoDB JavaScript execution in production: set
security.javascriptEnabled: falseinmongod.conf - Use
express-mongo-sanitizemiddleware as a defense-in-depth layer - Least privilege DB users — your app user shouldn't have
$whereor admin access
// Quick defense-in-depth: sanitize middleware
const mongoSanitize = require('express-mongo-sanitize');
app.use(mongoSanitize()); // Strips $ keys from req.body, req.query, req.params
This doesn't replace proper validation, but it's a good safety net.
The "But I Use an ORM" Defense 🤦
I've heard: "I use Mongoose, so I'm fine."
Partial credit. ORMs reduce risk but don't eliminate it. The moment you drop down to Model.findOne(req.body) or use $where, you're back in dangerous territory. Mongoose's protection is only as strong as how strictly you use it.
In my experience building production systems, the defense is always multiple layers: validate input types → sanitize operators → use parameterized queries where possible → restrict database user permissions. No single layer is enough.
Why This Matters More Than You Think 🎯
In security communities, we often discuss how MongoDB's prevalence in the Node.js ecosystem means there's a massive attack surface of Express + MongoDB apps written by developers who understood SQL injection but never heard of NoSQL injection.
OWASP literally lists "Injection" as a top web vulnerability because the concept never went away — just the syntax changed. If your app takes user input and puts it anywhere near a database query, you need to think about injection. Full stop.
The attacker doesn't care if your database is called "NoSQL." They just care if it has your data.
TL;DR 📋
- NoSQL injection is real, common, and exploits MongoDB query operators like
$ne,$gt,$where - Always validate that user input is the type you expect (string, number) before using it in queries
- Never pass raw
req.bodyto MongoDB queries - Use
express-mongo-sanitizeas a defense-in-depth layer - Disable MongoDB JavaScript engine (
security.javascriptEnabled: false) in production - SQL went away; injection didn't
Building Node.js + MongoDB systems and want to swap notes? Find me on LinkedIn — I'm always happy to talk about architecture and security war stories.
Want more security deep-dives? The blog has you covered. Check the cybersecurity tag! 🔐
Stay paranoid. Validate everything. 🛡️