Web Foundations & Databases · intermediate · ~11 min

NoSQL, password storage, and database hardening

- 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.

Overview

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:

  • Password storage. When (not if) a database copy leaks, the question is how fast attackers can turn it into working logins. Slow salted hashes make that expensive.
  • Database least privilege. When an injection or leaked credential happens, the app's database account decides the blast radius. An account that can only read the 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.

Why it matters

These three issues are common, are easy to get wrong, and each one alone can sink a product.

  • NoSQL injection bypasses authentication. Apps that build object queries from request bodies (very common in JavaScript/Node stacks) can be tricked into logging an attacker in as any user — often with a single crafted JSON value and no password.
  • Weak password hashing turns one leak into millions of compromised accounts. Databases get copied through backups, misconfigured cloud buckets, and other breaches. If passwords are plaintext or fast-hashed (MD5, SHA-256), attackers crack them in bulk and then reuse them on banks, email, and other sites (credential stuffing).
  • Over-privileged DB accounts amplify everything. If the web app connects as a database administrator, a single injected query can read every customer's data, modify balances, or delete the whole dataset. Least privilege is the cheapest way to shrink that risk.

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.

Core concepts

1. NoSQL databases

Definition. A NoSQL ("not only SQL") database stores data without a fixed relational table schema. The two flavors you will meet most:

  • Document stores (MongoDB): data is a collection of JSON-like documents. Queries are objects that describe which documents to match.
  • Key/value stores (Redis): data is 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?

2. Operators and operator 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?

3. Password storage

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.

  • Salt = random data added per password so two users with the same password get different hashes. This defeats precomputed "rainbow table" attacks. (These algorithms generate and store the salt for you, usually inside the output string.)
  • Slow / tunable cost = the algorithm is deliberately expensive (a work factor you can raise over time). Slow verification is barely noticeable for one honest login but crippling for an attacker trying billions of guesses.

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 compares sha256(submitted) == stored. Name two separate problems with this scheme.

4. Database least privilege

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.

Syntax notes

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.
  • You look up the user by 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.

Lesson

Not all databases are relational, and not all injection is SQL.

NoSQL basics

NoSQL databases (such as MongoDB and Redis) store documents or key/value data instead of tables. MongoDB queries are JSON-like objects.

NoSQL injection

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:

  • Treat input as data, never as query operators.
  • Validate input types before using them.

Password storage (do this right)

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.

Database hardening

  • Least privilege. The app's DB account should only do what it needs, not act as the DB admin. This caps the blast radius of any injection.
  • Sensitive data. Encrypt data at rest where required, never log secrets, and segregate PII (personally identifiable information).

Code examples

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.

Line by line

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.

Common mistakes

Mistake 1 — Trusting that JSON fields are strings

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.

Mistake 2 — Storing fast hashes or plaintext

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.

Mistake 3 — Putting the password in the query filter

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.

Mistake 4 — Running the app as database admin

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.

Debugging tips

Logic errors (the dangerous kind here):

  • A login succeeds without the right password. Check whether any request field reaches a query as a non-string. Log the type of each auth input (typeof body.password) — never the value — and confirm operator objects are rejected.
  • Everyone fails to log in after adding hashing. You are probably comparing a fresh hash to the stored hash with == 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.
  • Connection/auth errors to the DB: your scoped account may lack a needed permission — grant the minimum operation, do not jump to admin.

Questions to ask when it doesn't work:

  1. Could this input be an object instead of a string? Did I check its type?
  2. Is the password ever part of a query filter? (It should not be.)
  3. Am I comparing hashes manually instead of using verify?
  4. What can this DB account do that the app never legitimately needs?

Memory safety

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):

  • Send { "username": "alice", "password": { "$ne": null } } → expect rejection (HTTP 400 / login failure), not success.
  • Send the correct password → expect success.
  • Send a wrong password → expect failure.

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).

Real-world uses

Concrete uses.

  • MongoDB-backed web apps (common in Node/Express stacks) build login and search filters from request bodies — exactly where operator injection appears if input is not type-checked.
  • Every authentication system (web, mobile backends, internal tools) must store password verifiers; bcrypt/scrypt/Argon2 are the industry standard, and major frameworks ship with them.
  • Managed database services (cloud Mongo/Postgres/Redis) make it easy to create scoped roles, so least privilege is a standard production practice.

Professional best-practice habits.

Beginner rules:

  • Validate input types at the boundary; reject unexpected shapes.
  • Never store plaintext passwords; always use bcrypt/scrypt/Argon2 via a library.
  • Keep the password out of the query filter — look up by identity, then verify.
  • Do not connect the app as the database admin.

Advanced habits:

  • Use a schema/validation layer (e.g., a request validator) so operator objects can never reach queries.
  • Choose and periodically raise the hashing work factor as hardware improves; plan a re-hash-on-login migration.
  • Encrypt sensitive data at rest where required, segregate PII, and define per-service scoped DB roles.
  • Add monitoring for injection-probing patterns and brute-force/credential-stuffing signals; review DB role grants regularly.

Practice tasks

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.

Summary

  • Injection is not limited to SQL. NoSQL operator injection (e.g., an injected $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.
  • Keep the password out of the filter. Look up the user by identity, then verify with a hashing library; never compare against a reversible value.
  • Store passwords as slow, salted hashes with bcrypt, scrypt, or Argon2 — never plaintext, never fast hashes (MD5/SHA). Use the library's verify, not manual ==.
  • Least privilege limits the blast radius. The app's database account should do only what it needs and must not be an admin account.
  • Most important syntax: a type guard (typeof x !== "string" -> reject), argon2.hash / argon2.verify (or bcrypt equivalents), and lookup-then-verify.
  • Common mistakes to remember: trusting JSON field types, fast/plaintext password storage, putting the password in the query, and running the app as DB admin.
  • Together with SQL injection and prepared statements, these defenses round out the database-security foundation: stop the injection, protect the secrets, contain the damage.

Practice with these exercises