Web Foundations & Databases · intermediate · ~11 min
- Explain what a NoSQL database is and how its object/document queries differ from text-based SQL. - Recognize **NoSQL operator injection** (for example an injected `$ne` or `$gt`) and describe why it has the same root cause as SQL injection. - Build a login query that treats input as **data, not operators**, and validate input types before querying. - Store passwords correctly with a **slow, salted hash** (bcrypt, scrypt, or Argon2) and explain why fast hashes and plaintext fail. - Apply **database least privilege** so the app account cannot read or destroy more than it needs. - Describe defensive habits: type validation, parameterized/object-safe queries, encryption at rest, and logging that never records secrets.
Not every database is relational, and not every injection attack is SQL. In the prerequisite lesson SQL injection and prepared statements you learned that the danger comes from untrusted input changing the structure of a query. That same idea travels to a different world of databases — and to two closely related defenses that decide how badly a breach hurts.
A NoSQL database (such as MongoDB or Redis) does not store data in tables of rows and columns. It stores documents (JSON-like objects) or simple key/value pairs. Because of that, many NoSQL queries are not text strings — they are objects. A login lookup in MongoDB might be the object { username: "alice", password: "hunter2" } rather than the string SELECT ... WHERE username = 'alice'.
It is tempting to think "queries are objects, so injection can't happen." That is false. If the application drops raw user input straight into the query object, an attacker can supply a value that is itself a query operator — a special key like $ne ("not equal") — and bend the query to their will. The structure changed; authentication breaks. This is NoSQL injection, and the fix is the same discipline you already know: treat input as data, and validate its type.
This lesson then ramps into two defenses that surround every database, SQL or NoSQL:
users collection cannot drop every table.Together these three topics complete the database-security foundation: stop the injection, protect the secrets, and contain the damage.
These three issues are common, are easy to get wrong, and each one alone can sink a product.
In professional code review, finding plaintext/fast-hashed passwords or an app running as DB admin is treated as a serious, must-fix finding — not a nitpick.
Definition. A NoSQL ("not only SQL") database stores data without a fixed relational table schema. The two flavors you will meet most:
key -> value, accessed by exact key.Why it exists. Documents map naturally onto the objects an app already uses, and these systems scale out across many machines easily. The trade-off is fewer built-in guarantees than a relational database, which pushes more responsibility onto your code — including input validation.
Structure (a MongoDB find).
find( <filter object> )
|
v
{ username: "alice", password: "hunter2" }
\______ keys are field names
values are what each field must equal
When to use NoSQL: flexible/changing document shapes, high write volume, simple key lookups. When NOT to: data with many relationships and strict consistency needs — a relational database with joins and transactions is usually a better fit.
Pitfall. Assuming "objects can't be injected." The object can contain attacker-chosen keys, and some keys are operators.
Knowledge check (explain in your own words): Why is a MongoDB filter being an object not enough, by itself, to prevent injection?
Definition. A query operator is a special key the database interprets as a comparison or logic instruction rather than a literal value. Common ones:
| Operator | Meaning |
|---|---|
$eq |
equal to |
$ne |
not equal to |
$gt / $lt |
greater / less than |
$in |
matches any value in a list |
$regex |
matches a pattern |
How the attack works. Suppose a login does this with values taken directly from a JSON request body:
filter = { username: body.username, password: body.password }
If the attacker sends this JSON body:
{ "username": "alice", "password": { "$ne": null } }
then body.password is not the string the code expected — it is the object { "$ne": null }. The filter becomes:
{ username: "alice", password: { $ne: null } }
means: username is alice AND password is NOT null
Almost every account has some password, so this matches alice and logs the attacker in with no password. The structure of the query changed — exactly the SQL-injection root cause, in a new shape.
When operators are fine: when your code chooses them deliberately (e.g., a range filter you wrote). When NOT: never let an operator arrive as user input.
Pitfall. Trusting Content-Type: application/json bodies. Parsed JSON can contain nested objects, so a field you assumed was a string can secretly be an operator object.
Untrusted input
|
v
[ type check ] --- not a string? ---> reject (400)
|
is a string
v
safe equality filter: { username: name, password: pass }
Knowledge check (predict the output): A login filter is
{ user: body.user, pass: body.pass }. The attacker sends{ "user": { "$gt": "" }, "pass": { "$gt": "" }}. What kind of records would$gt: ""tend to match, and why is that dangerous for a login?
Definition. Password storage is how you keep a verifier for a password without keeping the password itself in a reversible form.
The rule. Store a slow, salted hash produced by a purpose-built password algorithm: bcrypt, scrypt, or Argon2.
Why fast hashes fail. MD5 and SHA-256 are designed to be fast. An attacker with a leaked database and a GPU can try billions of guesses per second, so fast hashes (and obviously plaintext) fall quickly.
Wrong: password ----SHA-256----> hash (fast, no salt -> crackable in bulk)
Right: password --bcrypt(salt,cost)--> $2b$12$... (slow + per-user salt)
When NOT to roll your own: always. Never invent your own hashing scheme or use plain SHA for passwords. Use a vetted library.
Pitfall. Forgetting that you never decrypt a password hash. To check a login you re-run the same algorithm on the submitted password and let the library compare — you do not reverse the stored value.
Knowledge check (find the bug): A developer stores
sha256(password)with no salt, and to log in comparessha256(submitted) == stored. Name two separate problems with this scheme.
Definition. Least privilege means the application connects to the database using an account that has only the permissions it actually needs — and nothing more.
Why it matters. Privilege decides the blast radius. If an injection succeeds, an admin account lets the attacker read everything and DROP everything; a narrowly scoped account might only be able to read the one collection the app legitimately uses.
App connects as ADMIN App connects as LEAST-PRIVILEGE
+--------------------+ +-----------------------------+
| read ANY table | | read/write users collection |
| drop databases | | (that's it) |
| create users | +-----------------------------+
+--------------------+
one bug = total loss one bug = limited, contained loss
When to relax it: never for the public-facing app account. Migrations and admin tooling use separate higher-privilege accounts, run only when needed, not the everyday connection string.
Pitfall. Using the database root/admin user in the app's connection string "just to make it work," then never tightening it.
Knowledge check (concept): Two apps each have a SQL/NoSQL injection bug. App A connects as admin; App B connects as a read-only account scoped to one collection. Describe the difference in worst-case outcome.
These are concept-track illustrations (JavaScript-style pseudocode for a MongoDB driver), showing the shape of safe vs. unsafe queries.
// UNSAFE: raw request fields dropped into the filter.
// body.password could be an object like { $ne: null }.
const user = await users.findOne({
username: body.username,
password: body.password
});
// SAFE: force fields to be strings before querying.
if (typeof body.username !== "string" || typeof body.password !== "string") {
return reply(400, "invalid input"); // reject non-string types
}
const user = await users.findOne({ username: body.username }); // look up by name only
if (user && await argon2.verify(user.passwordHash, body.password)) {
// password verified by the hashing library, not by storing the password
}
Key points annotated:
typeof ... !== "string" blocks operator objects from ever reaching the filter.username, then verify the password with the hashing library — the password is never part of the database filter.argon2.verify(storedHash, submitted) re-hashes the submitted password and compares; it does not decrypt anything.Not all databases are relational, and not all injection is SQL.
NoSQL databases (such as MongoDB and Redis) store documents or key/value data instead of tables. MongoDB queries are JSON-like objects.
Because queries are structured objects, injecting operators (special keys like $ne that change how a query matches) subverts them.
Consider a login that builds this query from user input:
{ username: input, password: input }
An attacker can send {"$ne": null} as the password. The query now means "password not equal to null," which matches any record. Authentication is bypassed.
The root cause is the same as SQL injection: untrusted input changes the query's structure. So is the fix:
Never store passwords in a form you can reverse.
Store a slow salted hash using a purpose-built algorithm: bcrypt, scrypt, or Argon2. These add a per-password salt (random data that makes identical passwords hash differently) and a tunable cost so they stay slow on purpose.
Plain MD5 or SHA-256 is the wrong choice. They are too fast, which makes them easy to crack at scale. Finding fast-hashed or plaintext passwords is a serious report finding.
Below is a self-contained, runnable Node.js example using MongoDB and Argon2. It shows the unsafe login, why it breaks, and the secure version side by side. Run it against a local MongoDB only (see the lab note in Security & safety).
// login-demo.js
// Requires: npm install mongodb argon2
// Run a LOCAL MongoDB first (e.g. a localhost container). Lab use only.
import { MongoClient } from "mongodb";
import argon2 from "argon2";
const uri = "mongodb://127.0.0.1:27017"; // local lab only
const client = new MongoClient(uri);
async function main() {
await client.connect();
const users = client.db("demo").collection("users");
await users.deleteMany({}); // clean slate for the demo
// Seed one user. Store a SLOW SALTED HASH, never the plaintext.
const hash = await argon2.hash("correct horse battery staple");
await users.insertOne({ username: "alice", passwordHash: hash });
// --- INSECURE login: input goes straight into the filter ---
async function insecureLogin(body) {
// If body.password is { $ne: null }, the filter matches alice.
const u = await users.findOne({
username: body.username,
passwordHash: body.password // WRONG: comparing hash to raw input AND trusting object input
});
return Boolean(u);
}
// --- SECURE login: validate types, look up by username, verify hash ---
async function secureLogin(body) {
if (typeof body.username !== "string" || typeof body.password !== "string") {
return false; // reject operator objects and any non-string input
}
const u = await users.findOne({ username: body.username });
if (!u) return false;
return argon2.verify(u.passwordHash, body.password); // library compares, no decryption
}
// Attacker payload: an operator object instead of a string password.
const attack = { username: "alice", password: { $ne: null } };
const honest = { username: "alice", password: "correct horse battery staple" };
const wrong = { username: "alice", password: "guess" };
console.log("insecure + attack object:", await insecureLogin(attack)); // true-ish risk shown below
console.log("secure + attack object: ", await secureLogin(attack)); // false (rejected)
console.log("secure + correct pass: ", await secureLogin(honest)); // true
console.log("secure + wrong pass: ", await secureLogin(wrong)); // false
}
main()
.catch(err => { console.error("error:", err); process.exitCode = 1; })
.finally(() => client.close()); // always release the connection
What it does. It seeds a user whose password is stored only as an Argon2 hash. It then runs the same three logins through an insecure path (input trusted as-is) and a secure path (types validated, password verified by the library).
Expected output (secure path). The secure login rejects the attack object (false), accepts the correct password (true), and rejects a wrong password (false). The insecure path demonstrates the danger: because it trusts the raw object, an operator payload can change the query's meaning. (In a real insecure app where the password field itself is matched, { $ne: null } is what bypasses the check.)
Edge cases. Missing fields (body.password undefined) — the type check rejects them. Empty-string username — the lookup simply finds no user. Connection failure — the catch logs an error and sets a non-zero exit code; finally still closes the client.
Walkthrough of the key example.
| Step | Code | What happens |
|---|---|---|
| 1 | new MongoClient(uri) / connect() |
Open a connection to the local lab database. |
| 2 | argon2.hash("...") |
Produce a slow, salted hash string (salt + cost are embedded in the output). |
| 3 | insertOne({ username, passwordHash }) |
Store only the hash — the plaintext password is never persisted. |
| 4 | attacker sends { username: "alice", password: { $ne: null } } |
password is an object, not a string. |
| 5 (insecure) | findOne({ username, passwordHash: body.password }) |
The driver sees an operator object as a value; the filter's meaning shifts away from "exact match," enabling bypass-style behavior. This is the structural change that defines injection. |
| 6 (secure) | typeof body.password !== "string" |
The object fails the type check, so the request is rejected before touching the database. |
| 7 (secure) | findOne({ username }) |
Look up the row by username only — the password is not part of the filter. |
| 8 (secure) | argon2.verify(u.passwordHash, body.password) |
Re-hash the submitted password and compare against the stored hash. Returns true only for the correct password. |
| 9 | finally(() => client.close()) |
Release the connection whether the run succeeded or threw. |
Why the result is produced. The secure path never lets an attacker-supplied operator reach the query (step 6) and never compares against a reversible secret (step 8). The insecure path fails because it trusts input shape and folds the secret into the filter.
Wrong:
const u = await users.findOne({ username: body.username, password: body.password });
Parsed JSON can deliver body.password = { $ne: null }, changing the query.
Corrected:
if (typeof body.username !== "string" || typeof body.password !== "string") return reject();
const u = await users.findOne({ username: body.username });
Prevent/recognize: validate types at the boundary; treat any non-string where you expected a string as hostile.
Wrong: store(sha256(password)) or storing the password directly.
Why wrong: SHA/MD5 are fast and (unsalted) crack in bulk; plaintext is game over on any leak.
Corrected: store(await argon2.hash(password)) (or bcrypt/scrypt).
Recognize: if you can see or recompute a password without per-user salt and a work factor, it is wrong.
Wrong: matching password: input (or even passwordHash: hashOf(input)) inside findOne.
Why wrong: it couples auth to the query layer and invites operator injection on the password field.
Corrected: fetch the user by username, then verify with the hashing library's verify.
Wrong: connection string uses the root account so "everything works."
Why wrong: any single bug becomes a full-database compromise.
Corrected: create a scoped account limited to the collections/operations the app needs; keep admin credentials out of the app.
Recognize: if the app can DROP/dropDatabase or read unrelated collections, privileges are too wide.
Logic errors (the dangerous kind here):
typeof body.password) — never the value — and confirm operator objects are rejected.== instead of using argon2.verify / bcrypt.compare. Hash outputs differ each time due to salt; you must use the library's verify.Runtime errors:
argon2.verify throws on a stored value: the stored field is not a valid hash (e.g., legacy plaintext). Migrate users on next login.Questions to ask when it doesn't work:
verify?Security & safety (this is a security-adjacent concept lesson).
Authorization / ethics: Only run the demo and any injection testing on systems you own or are explicitly authorized to test — a local MongoDB on 127.0.0.1, a container, or an intentionally vulnerable lab VM. Never send crafted payloads at third-party services.
Threat model (text):
Assets: user accounts, password verifiers (hashes), PII
Entry points: login endpoint (request body fields)
Trust boundary: --- network/request --- | --- your server ---
untrusted JSON | parsed into objects
Threats: operator injection (auth bypass), offline cracking
of leaked hashes, over-broad DB account
Insecure example (label): WARNING: Intentionally vulnerable training example — use only in a local, isolated, authorized lab. Do not deploy.
const u = await users.findOne({ username: body.username, password: body.password });
if (u) login(u); // bypassable with password: { $ne: null }
Why unsafe: attacker-supplied operator objects change the filter, bypassing authentication.
Secure fix: validate that auth fields are strings, look up by username only, and verify with a slow salted hash:
if (typeof body.username !== "string" || typeof body.password !== "string") return reject();
const u = await users.findOne({ username: body.username });
if (u && await argon2.verify(u.passwordHash, body.password)) login(u);
How to test the fix (mitigation verification):
{ "username": "alice", "password": { "$ne": null } } → expect rejection (HTTP 400 / login failure), not success.Detection / logging guidance: log the type and shape of rejected auth inputs and the count of failed logins per account/IP. Never log passwords, password hashes, tokens, or full connection strings. Alert on repeated non-string auth inputs (a sign of operator-injection probing) and on bursts of failures (credential stuffing).
Concrete uses.
Professional best-practice habits.
Beginner rules:
Advanced habits:
Beginner 1 — Spot the injectable field.
Objective: given a login that builds { user: body.user, pass: body.pass }, write down which crafted JSON body bypasses it and explain in one sentence why. Requirements: name the operator used. Hint: think $ne. Concepts: operator injection.
Beginner 2 — Add a type guard.
Objective: rewrite an unsafe login so it rejects any auth field that is not a string. Requirements: return a clear failure for non-string user or pass; only then query. Input/output example: body { "user": { "$gt": "" }, "pass": "x" } → rejected. Concepts: input validation.
Intermediate 1 — Hash and verify.
Objective: write functions register(username, password) and login(username, password) using a library hash (Argon2 or bcrypt). Requirements: store only the hash; login must use the library's verify, not ==; wrong passwords fail. Constraints: no plaintext anywhere; no manual hash comparison. Hint: the salt lives inside the hash string. Concepts: slow salted hashing.
Intermediate 2 — Design a least-privilege role.
Objective: describe (in words or as a role definition) the minimum permissions a login service needs against a users collection. Requirements: list allowed operations and explicitly exclude admin/drop. Hint: it likely needs read and password-update only. Concepts: least privilege, blast radius.
Challenge — Build a mini secure login + verification test.
Objective: implement a small login handler that (a) validates input types, (b) looks up by username, (c) verifies an Argon2/bcrypt hash, and (d) ship three tests proving: the { "$ne": null } payload is rejected, the correct password succeeds, and a wrong password fails. Constraints: localhost/lab only; never log secrets. Hint: assert on the boolean result of each case. Concepts: all four — NoSQL injection defense, type validation, password hashing, and (in comments) the least-privilege account it would run under. Do not hard-code a bypass; the test must pass because the code is correct.
$ne or $gt) has the same root cause — untrusted input changing a query's structure — and the same fix: treat input as data and validate its type before it reaches a query.verify, not manual ==.typeof x !== "string" -> reject), argon2.hash / argon2.verify (or bcrypt equivalents), and lookup-then-verify.