# NoSQL injection: how it works, how testers prove it, how to fix it

Source: https://penetrationtestservices.com/vulnerabilities/nosql-injection/
Publisher: Excession Labs (penetrationtestservices.com)
Last updated: 2026-11-09

NoSQL injection explained: MongoDB operator injection, a login demo, Rocket.Chat CVE-2021-22911, other databases, how testers prove it, and fixes for Node.js and Python.

> **Short answer:** NoSQL injection is a flaw where user input changes a NoSQL database query. In MongoDB apps the usual form is operator injection: a JSON body or a nested form field turns a string into an object such as one with a $ne key. Fix it by checking that each input is the expected type, keeping $where off, and enabling the framework's filter sanitizing.

NoSQL injection is a flaw that lets an attacker interfere with the queries an application sends to a NoSQL database. PortSwigger says it can allow an attacker to bypass authentication, read or change data, cause a denial of service, or run code on the server. CWE-943 describes the root cause as a product that "generates a query intended to access or manipulate data in a data store such as a database" and mishandles special characters or syntax in the input.

NoSQL injection testing is part of [API penetration testing](/api-penetration-testing/) and [web application penetration testing](/web-application-penetration-testing/). Testing needs written authorization from the system owner.

## What is NoSQL injection, and why is there no single syntax?

NoSQL injection is injection into a database that does not use SQL. There is no single syntax to attack because each family has its own query language, document format or command set. MongoDB queries are JSON-style objects. Redis uses commands and Lua scripts. Elasticsearch uses a JSON query language. DynamoDB uses expression strings with placeholders.

OWASP lists Injection as A05 in the Top 10:2025. NoSQL injection falls under that category, and it is related to [SQL injection](/vulnerabilities/sql-injection/). The cause is the same: untrusted input changes the meaning of a query. The mechanics differ.

PortSwigger splits NoSQL injection into two types. Syntax injection breaks the structure of the query, much like SQL injection. Operator injection supplies query operators such as `$ne`, `$where` or `$regex` to change how a query behaves. Operator injection is the type testers meet most often in JavaScript applications that use MongoDB.

## How does operator injection bypass a login?

Operator injection works when the application lets a user send an object where it expects a string. The database reads the object as a query condition, not as a value.

> **Vulnerable login (fictional app):** This is a demonstration. Harbor Notes is a fictional app on app.example.com, and no real customer system was tested.
> 
> The login route passes the parsed JSON body straight into a MongoDB query:
> 
> ```js
> // Vulnerable: req.body.password may be an object, not a string
> app.post('/api/login', async (req, res) => {
>   const user = await db.collection('users').findOne({
>     email: req.body.email,
>     password: req.body.password,
>   });
>   res.status(user ? 200 : 401).json({ ok: !!user });
> });
> ```
> 
> A normal request sends strings:
> 
> ```http
> POST /api/login HTTP/1.1
> Host: app.example.com
> Content-Type: application/json
> 
> {"email": "tester1@example.com", "password": "wrong-password"}
> ```
> 
> The tester then sends the same request with the password as an object that holds a comparison operator, using the `$ne` shape that PortSwigger documents:
> 
> ```http
> {"email": "tester1@example.com", "password": {"$ne": ""}}
> ```
> 
> The first request returns 401. The second returns 200, because the condition "password is not equal to empty" is true for a real account. Two accounts owned by the tester are enough to show it. The tester sends a control with a nonexistent email and the same operator, which must still return 401. If it also returns 200, the first result was misleading and the finding is dropped.
The two requests differ in the type of one field. That is why this flaw hides from filters that look for quote characters: there are none.

## How does syntax and JavaScript injection work?

Syntax injection and JavaScript injection need input that reaches a query string or a script. They are rarer than operator injection but have a wider reach.

The MongoDB manual says `$where` passes "either a string containing a JavaScript expression or a full JavaScript function to the query system" and that it "requires that the database processes the JavaScript expression or function for each document in the collection." If user input is joined into that string, the input is code that runs inside the database process. The manual also says `$where` "cannot take advantage of indexes," so it is slow as well as risky.

```js
// Risky: user input becomes part of a JavaScript expression
db.users.find({ $where: `this.username == '${req.query.name}'` });
```

Syntax injection has the same shape as SQL injection. A string query built by joining text can be closed early, and the rest of the input is then read as query logic. In MongoDB this needs the application to build JSON or JavaScript text by hand. Modern drivers take objects, so hand-built text is uncommon and is a code smell on its own.

## Which databases are affected beyond MongoDB?

Every NoSQL database that builds queries from user input can be affected. The risk follows the same rule: keep user values in the value slot, and keep operators, keys and script text under the application's control.

| Database | Query form | Where input becomes code or structure | Safer pattern, per vendor docs |
|---|---|---|---|
| MongoDB | JSON-style objects with `$` operators | Objects in value fields; `$where` strings | Check types; avoid `$where`; sanitize filters |
| CouchDB | JSON selectors (Mango) with `$` operators, in `_find` | Objects in value fields | Build the selector in code; check types |
| Elasticsearch | JSON Query DSL sent to `_search` | Raw JSON or script queries built from input | Build the DSL in code; the docs note that script queries can be costly and can be disabled with `search.allow_expensive_queries` |
| Redis | Commands; Lua through `EVAL` | Script text with values embedded | Pass values as `ARGV` arguments, not in the script text |
| DynamoDB | Expression strings | Names and operators built by concatenation | Use expression attribute values (`:value` placeholders) |

The CouchDB docs describe `_find` as "a declarative JSON querying syntax." The Redis docs describe the `EVAL` argument model: the first argument is the script, and values reach it through `KEYS` and `ARGV`. The DynamoDB docs describe expression attribute values as "variables" that stand in for "the actual values that you want to compare."

For Elasticsearch, the Elastic docs on Query DSL do not discuss user input. The risk follows from the format: if an application forwards a user's JSON to `_search`, the user controls the query.

## Why do Express and Mongoose settings matter?

Framework settings decide whether an attacker can turn a string into an object. Two settings matter most in Node.js.

First, the body parser. Express documents the `extended` option of `express.urlencoded()` this way: with `false` it parses with the `querystring` library, and with `true` it uses the `qs` library. The docs say `qs` allows "rich objects and arrays to be encoded into the URL-encoded format." The default is `false` in the Express 5 docs. With `extended: true`, a form field named `password[$ne]` can arrive as an object. The Express docs give the example `user[name]=Tobi` becoming `{ user: { name: 'Tobi' } }`. JSON bodies need no such trick, since `express.json()` builds objects from JSON.

Second, Mongoose. Its API docs list the `sanitizeFilter` option as `false` by default. When on, it protects query filters "by wrapping any nested objects that have a property whose name starts with `$` in a `$eq`." The same page lists `strictQuery` with a default of `false` in the version documented (v9.12.0). We did not check the earlier defaults for versions 6 and 7, so check your own version's docs.

Other data layers handle objects in filters in their own way. Review each one, and write a test that sends an operator object as input.

## Which real case has a date and a CVE ID?

CVE-2021-22911 in Rocket.Chat is a documented NoSQL injection. NVD published the record on 2021-05-27. It says "A improper input sanitization vulnerability exists in Rocket.Chat server 3.11, 3.12 & 3.13 that could lead to unauthenticated NoSQL injection, resulting potentially in RCE."

NVD links an advisory from the research side and a HackerOne report. We did not review exploit code and the page does not describe how the flaw is triggered. The lesson for defenders is in the record itself: an unauthenticated route reached a database query without proper input checks, and the effect went past data access.

## How do penetration testers detect and prove NoSQL injection?

Testers prove NoSQL injection with type-change evidence and controls, and they read no data that is not theirs. The method follows what we do for every injection class.

1. Baseline. Send the request with expected values and record status, length and key fields.
2. Type change. Where the API accepts JSON, send a field as an object with one operator. Where it takes form fields, try the nested bracket form. Change one field at a time.
3. Control. Repeat the change on a nonexistent identifier and with a wrong account. A control that behaves like the exploit means the first result was misleading.
4. Read-back. For a login bypass, use two accounts the tester owns and show that the operator request returns a session for the first account without its password. For a data change, read the result back through a second session. A bare 200 is not proof.
5. Stop. Do not read other users' records. Do not run regular-expression guessing against real passwords. A matched pair and a control are enough.

Browser-observed checks matter when the front end builds the request. Every request is saved with a timestamp. A finding names the endpoint, the field, the exact type change, the controls that held, and the fix.

Time-based and boolean inference apply here as well, in the same way as in [blind SQL injection](/vulnerabilities/blind-sql-injection/). PortSwigger's NoSQL page covers timing techniques for the cases where the response never changes.

## How do you prevent NoSQL injection?

Check the type of every input, keep operators out of user control, and turn on the filter protection your framework offers. PortSwigger recommends an allow-list of permitted characters and keys, and says that parameterized queries should be used instead of concatenating user input into a query.

Node.js with Express. Parse the fields as strings before you query:

```js
app.post('/api/login', express.json(), async (req, res) => {
  const { email, password } = req.body;
  if (typeof email !== 'string' || typeof password !== 'string') {
    return res.status(400).json({ error: 'invalid input' });
  }
  const user = await users.findOne({ email });
  const ok = user && (await verifyPassword(password, user.passwordHash));
  res.status(ok ? 200 : 401).json({ ok: !!ok });
});
```

This version does not put the password in the query at all. It fetches the user by email and compares a hash in code, which removes the operator target. It is the right shape for any credential check.

Mongoose. Turn on the filter sanitizer, and keep types in the schema:

```js
mongoose.set('sanitizeFilter', true);

const noteSchema = new mongoose.Schema({
  owner: { type: mongoose.Schema.Types.ObjectId, required: true },
  title: { type: String, required: true },
});
// A filter like { title: req.body.title } now has any $-prefixed object wrapped in $eq
const notes = await Note.find({ owner: req.user.id, title: req.body.title });
```

Python with PyMongo. PyMongo takes dictionaries, so the same problem applies to values from `request.json`:

```python
data = request.get_json()
email, password = data.get("email"), data.get("password")
if not isinstance(email, str) or not isinstance(password, str):
    abort(400)
user = users.find_one({"email": email})
if not user or not verify_password(password, user["password_hash"]):
    abort(401)
```

Java. Bind the request to a typed field, such as a `String email` in a request class, so a JSON object in that field fails to parse. Do not build query JSON strings by concatenation.

PHP. Form input with bracket names can arrive as an array, so check `is_string()` before you use a value:

```php
$email = $_POST['email'] ?? null;
$password = $_POST['password'] ?? null;
if (!is_string($email) || !is_string($password)) {
    http_response_code(400);
    exit;
}
```

For the database itself, turn off server-side JavaScript when you do not use it. The MongoDB manual says that if you do not use `$where`, `$function`, `$accumulator` or `mapReduce`, you should disable server-side scripting with the `security.javascriptEnabled` option or the `--noscripting` flag. Give the application account only the roles it needs.

## Which fix covers which attack?

| Attack type | Example shape | Fix |
|---|---|---|
| Operator injection | A field arrives as an object with a `$` key | Type check each field; `sanitizeFilter`; parse form bodies with `extended: false` unless needed |
| Syntax injection | Query text built by joining strings | Build queries as objects in code; never join text |
| JavaScript injection | Input reaches a `$where` string | Remove `$where`; use standard operators or `$expr`; disable server-side JavaScript |
| Script injection in Redis or similar | Values embedded in Lua text | Pass values through `ARGV` |
| Expression injection in DynamoDB | Operators or names built from input | Use expression attribute values and an allow-list of attribute names |

## How does NoSQL injection compare with SQL injection?

| Question | SQL injection | NoSQL injection |
|---|---|---|
| Common probe | A quote character breaks the string | A type change turns a string into an object |
| Why filters miss it | Filters look for quotes and keywords | The payload has no quotes: it is an object |
| Main fix | Parameterized queries | Type checks, no operators from users, filter sanitizing |
| Can ORMs help? | Yes, for values | Yes, with schema types and sanitizing turned on |
| Impact | Data access and change; sometimes more | Same, plus login bypass and server-side script risk |

## NoSQL injection testing checklist

1. List every endpoint that takes JSON, form or query input and touches a database.
2. Record a baseline for each endpoint: status, length, key fields.
3. Send each field as an object with one operator, one field at a time.
4. For form endpoints, test nested bracket fields. Check whether the body parser builds objects from them.
5. Test query-string parameters that become filters, such as search, sort and tag.
6. Run controls: nonexistent ID, wrong account, another role, unauthenticated request.
7. Check for `$where` or JavaScript evaluation in the code or the error messages.
8. Prove login bypasses with two test-owned accounts and a read-back from a second session.
9. Stop at the first proof. Do not read other users' records.
10. Save timestamped requests and responses and list what was not tested.

## When to get a professional test

NoSQL flaws sit in the glue between your API and your data layer, and scanners that look for quote characters miss them. A tester who reads your request formats and tries type changes on each field finds them and checks that your fixes hold. [API penetration testing](/api-penetration-testing/) from Excession Labs covers JSON bodies, filters and object-level checks, and [web application penetration testing](/web-application-penetration-testing/) covers the login and form paths. We agree the scope in writing first, and the report gives each finding with evidence and fix guidance.

## Questions

### What is the difference between NoSQL injection and SQL injection?

SQL injection breaks out of a string in one common language. NoSQL databases have no single syntax, so the flaw takes different forms. In MongoDB apps the common form is operator injection, where an input becomes an object with a $ key. Both share a root cause: untrusted input is read as part of the query.

### Can a login be bypassed with NoSQL injection?

Yes, when the handler passes the request body straight into a query. If the password field arrives as an object with a comparison operator instead of a string, the match condition can become true for any value. Check that the field is a string before you query.

### Does Mongoose protect against NoSQL injection?

Only if you turn it on. The Mongoose docs describe the sanitizeFilter option as wrapping nested objects that have a $ key in an $eq, and list it as false by default. Check the type of each input as well.

### Why is $where dangerous?

The MongoDB manual says $where passes a string or function of JavaScript to the query system and evaluates it for each document. If user input reaches that string, the input is code. $where also cannot use indexes. Use standard operators, and disable server-side JavaScript if you do not need it.

### How do you test for NoSQL injection?

Send each input in its expected form, then as an object or array where the API accepts JSON or nested form fields. Compare the responses to a control. If a type change alters the result in the way an operator would, record the pair as evidence and stop. Do not read other users' records.

## Sources

- [PortSwigger Web Security Academy: NoSQL injection](https://portswigger.net/web-security/nosql-injection)
- [CWE-943: Improper Neutralization of Special Elements in Data Query Logic](https://cwe.mitre.org/data/definitions/943.html)
- [OWASP Top 10:2025 Introduction](https://top10.owasp.org/2025/0x00_2025-Introduction)
- [NVD: CVE-2021-22911 (Rocket.Chat)](https://nvd.nist.gov/vuln/detail/CVE-2021-22911)
- [MongoDB Manual: $where](https://www.mongodb.com/docs/manual/reference/operator/query/where/)
- [Mongoose API documentation: sanitizeFilter and strictQuery](https://mongoosejs.com/docs/api/mongoose.html)
- [Express documentation: express.urlencoded() and the extended option](https://expressjs.com/en/5x/api/express.html)
- [Amazon DynamoDB: Using expression attribute values](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ExpressionAttributeValues.html)
- [Redis documentation: EVAL](https://redis.io/docs/latest/commands/eval/)
- [Elastic documentation: Query DSL](https://www.elastic.co/docs/explore-analyze/query-filter/languages/querydsl)
- [Apache CouchDB documentation: /{db}/_find](https://docs.couchdb.org/en/stable/api/database/find.html)
