Opening: Why NoSQL injection matters
We discovered it the hard way on a Tuesday morning: a support ticket showing users could read other customers' data by tweaking request parameters. NoSQL injection: MongoDB & NoSQL lessons from production is not a theoretical concern—it's a real vulnerability that catches teams off guard because they assume NoSQL databases are somehow immune to injection attacks. The truth is that MongoDB, and NoSQL databases in general, are just as exploitable as SQL databases when developers treat user input as trusted code.
Over fifty days of this series, we've built solid data models, optimized queries, and scaled our systems. Today we're stepping back to secure them properly. A single overlooked input validation can undo months of architecture work.
Never pass raw request bodies as filters
The most common mistake we see in production is developers who reach for the request body and use it directly as a MongoDB filter. Consider a typical REST endpoint for fetching user records:
app.get('/users', async (req, res) => {
// DANGEROUS: req.body is user-controlled
const users = await db.collection('users').find(req.body).toArray();
res.json(users);
});At first glance, this looks clean. You're filtering by whatever the client sends. But an attacker can send:
{
"email": {"$regex": ".*"},
"role": {"$ne": "user"}
}Now your "simple filter" becomes a query that bypasses your intended constraints. The attacker just retrieved all non-user roles by exploiting operators you never meant to expose.
The fix: be explicit about which fields you allow and their expected types. Whitelist, don't blacklist:
app.get('/users', async (req, res) => {
// SAFE: explicit field allowlist
const filter = {};
const allowedFields = ['email', 'username', 'status'];
for (const field of allowedFields) {
if (field in req.body) {
filter[field] = req.body[field];
}
}
const users = await db.collection('users').find(filter).toArray();
res.json(users);
});Better yet, use a schema validation library like joi or zod to define exactly what shape the input should be. This catches structural attacks before they reach your database layer.
Reject keys starting with $
MongoDB operators all start with a dollar sign: $ne, $regex, $in, $gt, and so on. If your filter construction doesn't explicitly reject these, an attacker can inject them directly into your queries.
Here's a real scenario from a booking platform we worked with: they let users search flights by price range using a simple text field:
app.get('/flights', async (req, res) => {
const { minPrice } = req.query;
// DANGEROUS: minPrice could contain operators
const flights = await db.collection('flights')
.find({ price: { $gte: minPrice } })
.toArray();
res.json(flights);
});An attacker sends minPrice={"$regex":".*"} and the entire filter becomes { price: { $gte: { $regex: ".*" } } }. While this particular example might not cause harm, the pattern is dangerous. If your code dynamically builds operator expressions, injected operators become weapons.
Protect yourself with a simple check:
function sanitizeFilter(obj) {
if (obj === null || typeof obj !== 'object') {
return obj;
}
for (const key in obj) {
if (key.startsWith('$')) {
throw new Error(`Operator keys not allowed: ${key}`);
}
if (typeof obj[key] === 'object') {
sanitizeFilter(obj[key]);
}
}
return obj;
}
app.get('/flights', async (req, res) => {
const filter = sanitizeFilter(req.query);
const flights = await db.collection('flights').find(filter).toArray();
res.json(flights);
});This recursive check prevents operators at any depth in your filter object. It's a simple gate that stops most injection attempts cold.
Validate input types
Type coercion is a silent killer. JavaScript and Node.js are lenient about converting strings to other types, and that leniency can become a vulnerability when you're not explicit.
Imagine a user management service that filters by user ID:
app.get('/user/:id', async (req, res) => {
// DANGEROUS: id is a string from the URL
const user = await db.collection('users').findOne({ _id: req.params.id });
res.json(user);
});If your _id field is actually an ObjectId, not a string, this query might return nothing or return the wrong user. More critically, if an attacker sends /user/{"$ne":null}, you've just accidentally enabled a query operator injection because MongoDB will accept the string representation and may interpret it differently on different driver versions.
Always validate types explicitly:
const { ObjectId } = require('mongodb');
app.get('/user/:id', async (req, res) => {
try {
// Validate that id is a valid ObjectId
if (!ObjectId.isValid(req.params.id)) {
return res.status(400).json({ error: 'Invalid user ID format' });
}
const userId = new ObjectId(req.params.id);
const user = await db.collection('users').findOne({ _id: userId });
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Database error' });
}
});Type validation isn't just about security; it's about correctness. A price filter should be a number, a boolean flag should be a boolean. Use libraries like joi, zod, or even simple typeof checks to enforce these contracts.
Use sanitization middleware
Rather than repeating validation logic in every route, centralize it in middleware. This ensures consistent protection across your entire API and makes it easier to audit your security posture.
Here's a middleware pattern we've used successfully across multiple production services:
function mongoSanitizeMiddleware(req, res, next) {
const sanitizeValue = (val) => {
if (val === null || val === undefined) return val;
if (typeof val === 'string') {
// Reject strings that look like operators
if (val.startsWith('$')) {
throw new Error('Operator syntax not allowed');
}
return val;
}
if (typeof val === 'object') {
if (Array.isArray(val)) {
return val.map(sanitizeValue);
}
for (const key in val) {
if (key.startsWith('$')) {
throw new Error(`Operator key not allowed: ${key}`);
}
val[key] = sanitizeValue(val[key]);
}
return val;
}
return val;
};
try {
if (req.body) req.body = sanitizeValue(req.body);
if (req.query) req.query = sanitizeValue(req.query);
next();
} catch (error) {
res.status(400).json({ error: error.message });
}
}
// Apply globally
app.use(mongoSanitizeMiddleware);This middleware operates at the entry point to your application, catching malicious input before it reaches your business logic. It's not a replacement for schema validation, but it's a fast first line of defense.
Common mistakes
- Trusting the client: Assuming frontend validation is enough. Validation must happen server-side, always. Frontend checks are UX; server checks are security.
- Relying on libraries without understanding them: Using an ORM or schema validator is good, but if you don't know what it's doing under the hood, you might disable its protections accidentally. Always read the docs for security-related features.
- Mixing sanitization and validation: Sanitization (removing dangerous characters) and validation (checking structure) are different. Prefer validation; sanitization can be fragile if your threat model changes.
- Forgetting about nested queries: Attackers can inject operators deep in nested objects. A shallow check isn't enough; sanitize recursively.
- Logging unsanitized input: If you log request bodies for debugging, you might be logging injection attempts with operators intact. Sanitize before logging, or exclude sensitive fields.
Key takeaways
- Never pass raw request bodies directly to MongoDB queries. Use explicit field whitelists and schema validation instead.
- Reject any filter key or value that starts with
$. MongoDB operators are a common attack vector. - Validate that input types match your expectations. ObjectIds should be validated with
ObjectId.isValid(), numbers should be numbers, not strings. - Centralize sanitization and validation in middleware so it's applied consistently across your entire API.
- Remember that NoSQL injection is as real as SQL injection. The attack vector is different, but the damage is the same.
- Test your security. Try injecting operators in your test suite; make sure they're rejected before they reach the database.






Comments
Loading comments…