Blog/Injection
InjectionAugust 6, 2026 · 7 min read

NoSQL Injection: When $gt and $where Bypass Your Login

Your MongoDB login looks clean. There's no string concatenation anywhere, so surely there's nothing to inject:

const user = await User.findOne({
  username: req.body.username,
  password: req.body.password,
});
if (user) grantSession(user);

Then an attacker sends this JSON body:

{ "username": "admin", "password": { "$gt": "" } }

req.body.password is no longer a string — it's the object { "$gt": "" }, a MongoDB query operator meaning "greater than empty string," which every stored password satisfies. The query becomes "find the admin user whose password is anything," and the attacker is logged in as admin without knowing the password. No quotes, no OR 1=1, no SQL at all — but the same root cause: untrusted data changed the shape of the query.

The two flavors of NoSQL injection

Operator injection. Because MongoDB queries are just objects, and JSON request bodies deserialize into objects, an attacker who controls a value can substitute an operator object for the plain value you expected. The workhorses:

  • {"$ne": null} — "not equal to null," matches almost everything
  • {"$gt": ""} — "greater than empty," matches every non-empty value
  • {"$regex": "^adm"} — pattern matching, useful for extracting data character by character
  • {"$in": [...]} — match against an attacker-supplied list

JavaScript injection via $where. Some queries accept a $where clause containing JavaScript that runs on the database server. If user input reaches it, the attacker isn't just bypassing a filter — they're running code:

// Dangerous: user input inside server-side JS
db.users.find({ $where: `this.username === '${input}'` });
// input = "'; return true; //"  ->  matches every document
// input = "'; while(true){} //" ->  denial of service

Blind operator injection with $regex also enables data exfiltration: by asking "does the admin's password start with a? with b?" and watching which requests succeed, an attacker can extract secrets one character at a time — the NoSQL version of a blind SQL injection.

Where it comes from

Almost every NoSQL injection traces back to one habit: passing a request body or query string straight into a database filter without checking its type. Common entry points:

  • Login and lookup endpoints that spread req.body into a find filter
  • Search features that forward query parameters into $regex or $where
  • APIs that accept a JSON "filter" object from the client and run it directly
  • Query-string parsers (like qs) that turn ?password[$ne]= into a nested object for you — turning even a GET request into operator injection

The fix: make values be values

The defense is to guarantee that a field you expect to be a string can never arrive as an operator object. There are three complementary ways to enforce that.

// 1) Validate the request body with a schema BEFORE it hits the DB.
//    A string field that arrives as an object is rejected outright.
const { z } = require('zod');

const LoginSchema = z.object({
username: z.string().min(1).max(64),
password: z.string().min(1).max(200),
});

app.post('/login', async (req, res) => {
const parsed = LoginSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).send('Invalid input');

const { username, password } = parsed.data; // guaranteed strings

// Even now, look up by username only, then verify a HASH in code —
// never compare raw passwords in the query.
const user = await User.findOne({ username });
if (user && await bcrypt.compare(password, user.passwordHash)) {
  return grantSession(user);
}
res.status(401).send('Invalid credentials');
});

The principles behind the code

Strip the language away and NoSQL injection defense comes down to three rules:

  • Type every input. A field you treat as a string must be a string by the time it reaches the query. Validate with a schema (Zod, Joi, Pydantic, a Mongoose schema with strict types) so an object can never masquerade as a value.
  • Never let user input choose operators or run code. Build filters yourself. Don't accept a raw filter object from the client, and disable $where/mapReduce server-side scripting entirely.
  • Don't compare secrets in the query. Look records up by a public identifier, then verify passwords and tokens in application code with a constant-time hash comparison. A query should never contain a raw secret to match against.

The one-line version: in MongoDB, an object where you expected a string is an attack. Guarantee the type and the whole class of bug disappears.

See it live in the NoSQL injection simulation — log in as admin with a $gt payload, then add schema validation and watch it fail. For the relational cousin, read the SQL injection prevention guide; the databases differ but the lesson is identical.

Share this post

Frequently Asked Questions

Related posts

Server-Side Template Injection: From {{7*7}} to RCE

SSTI happens when user input is rendered as part of a server-side template instead of passed as data. Here's how {{7*7}} becomes remote code execution, and how to render templates safely in Jinja2, Twig, and Node.js.

Aug 8, 2026 · 7 min read

LDAP Injection: Bypassing Auth Through the Directory

LDAP injection lets an attacker rewrite a directory query with characters like * ( ) and | — bypassing login and dumping user data. Here's how the filter syntax gets abused and how to escape it correctly in Node.js, Python, and Java.

Aug 7, 2026 · 7 min read

Path Traversal: Reading Files You Were Never Meant to Reach

Path traversal (directory traversal) turns a file parameter into a key to your whole filesystem. Here's how ../ attacks work, why string filtering fails, and how to canonicalize and confine paths in Node.js, Python, Java, and PHP.

Aug 5, 2026 · 8 min read