NoSQL
NoSQL Security Best Practices
In 2017, tens of thousands of MongoDB and Elasticsearch instances were wiped clean and held for ransom — not by some sophisticated zero-day exploit, but because they were left exposed to the internet with no authentication. Security is not a feature you bolt on after launch. It is the foundation everything else stands on, and in NoSQL systems, the defaults are deliberately open for developer convenience — which means you have to close them deliberately for production.
The NoSQL Security Threat Landscape
NoSQL databases face a unique threat profile. The schema flexibility and horizontal scale that make them powerful also expand the attack surface. Here is what you are defending against:
$ne, $gt, or $where into query parameters to bypass authentication or dump entire collections.root for convenience. One compromised account becomes a full cluster breach with no blast radius limit.Layer 1 — Network Hardening and Authentication
The first line of defence: make sure only the right machines can reach your database port, and only the right users can authenticate. In older MongoDB versions, the default bindIp was 0.0.0.0 — listening on every network interface including the public one. That single default caused the 2017 ransomware wave.
The scenario: You are the infrastructure engineer at a Series B fintech startup. Your MongoDB cluster runs on AWS EC2. A penetration test is scheduled for next week and your CISO has asked you to prove the database is not reachable from the public internet and requires authentication before any query runs. You have 30 minutes.
# /etc/mongod.conf
net:
# Bind ONLY to private IP + localhost — never 0.0.0.0
bindIp: 127.0.0.1,10.0.1.45
port: 27017
tls:
mode: requireTLS # Reject all plain connections
certificateKeyFile: /etc/ssl/mongo.pem # Server cert + private key
CAFile: /etc/ssl/ca.pem # Trusted CA for client verification
security:
authorization: enabled # Enforce RBAC — no credentials = no access
javascriptEnabled: false # Disable $where operator (server-side injection)
$ mongod --config /etc/mongod.conf
{"s":"I","c":"NETWORK","msg":"Listening on","attr":{"address":"127.0.0.1"}}
{"s":"I","c":"NETWORK","msg":"Listening on","attr":{"address":"10.0.1.45"}}
{"s":"I","c":"ACCESS", "msg":"Authorization is enabled"}
{"s":"I","c":"SCRIPTING","msg":"JavaScript execution is disabled"}
$ mongo --host 10.0.1.45 --tls # attempt without credentials
MongoServerError: command find requires authenticationbindIp: 127.0.0.1,10.0.1.45
MongoDB only accepts TCP connections on these two addresses. Leave it as 0.0.0.0 (the old default) and every network interface listens — including your public IP. That is the exact configuration that made 45,000 MongoDB instances ransomware targets in a single weekend in 2017.
tls.mode: requireTLS
Any client connecting without TLS is rejected outright. The weaker option, allowTLS, accepts both encrypted and plain connections — acceptable during a migration window, not acceptable in production where credentials transit the wire in clear text.
authorization: enabled
Activates Role-Based Access Control. Without this line, any authenticated connection — or any connection at all if you skipped TLS — has full superuser read-write access to every database on the instance. This is the default you are overriding.
javascriptEnabled: false
Disables server-side JavaScript — specifically the $where operator and mapReduce. These are the primary vectors for NoSQL injection attacks where query operators are smuggled through unsanitised user input. Disabling them removes the attack surface entirely at the config level.
Layer 2 — Role-Based Access Control (RBAC)
Authentication proves who you are. Authorisation controls what you can do. The principle of least privilege means every user and every application gets the minimum permissions needed — nothing more. A compromised account can only damage what it had access to.
| Role | Scope | Use it for |
|---|---|---|
read |
Single DB | Analytics / reporting services |
readWrite |
Single DB | Application service accounts |
dbAdmin |
Single DB | Index management, schema stats |
clusterAdmin |
Cluster-wide | DBA / ops team only |
root |
Everything | Break-glass emergency only — never service accounts |
The scenario: Your payments microservice writes transaction records to MongoDB. Your analytics service reads from the same database for reporting. Your deployment pipeline manages indexes. Three distinct jobs — three separate users, each locked to exactly what they need and nothing else.
// Step 1: app service — read/write on payments DB only
db.createUser({
user: "payments_svc",
pwd: passwordPrompt(), // never hardcode — prompts interactively
roles: [{ role: "readWrite", db: "payments" }]
});
// Step 2: analytics — read-only, same DB
db.createUser({
user: "analytics_svc",
pwd: passwordPrompt(),
roles: [{ role: "read", db: "payments" }]
});
// Step 3: deploy pipeline — index management only
db.createUser({
user: "deploy_svc",
pwd: passwordPrompt(),
roles: [{ role: "dbAdmin", db: "payments" }]
});
{ ok: 1 } // payments_svc created
{ ok: 1 } // analytics_svc created
{ ok: 1 } // deploy_svc created
// Verify blast radius — analytics_svc attempts a write:
db.auth("analytics_svc", "...")
db.transactions.insertOne({ amount: 500 })
MongoServerError: not authorized on payments to execute
command { insert: "transactions", ... } ✓passwordPrompt()
Forces the shell to prompt for the password interactively instead of embedding it in the command string. This prevents the password from appearing in shell history, audit logs, screen recordings, or CI/CD pipeline output — all places where hardcoded credentials routinely leak.
roles: [{ role: "read", db: "payments" }]
The role is scoped to a specific database. analytics_svc cannot read from any other database on the cluster — if you add a users database next month it is invisible to this account automatically. No policy update required.
MongoServerError: not authorized
RBAC is working. analytics_svc is authenticated — the cluster knows who it is — but it is denied the write because its role does not include insert privileges. Authentication and authorisation are two separate layers. Both must be configured.
Layer 3 — Defending Against NoSQL Injection
SQL injection is famous. NoSQL injection is its quieter, equally dangerous cousin. When user-supplied data flows directly into a query object, attackers inject MongoDB operators to manipulate query logic entirely — bypassing authentication without knowing a single password.
An attacker sends this as the JSON body of a login request:
{ "username": "admin", "password": { "$ne": "" } }
// $ne = "not equal" — password "not equal to empty string"
// Matches EVERY user with any password set.
// Authentication bypassed. Admin session returned.
The scenario: You are reviewing a Node.js Express API before it goes to production. You find the login endpoint passes req.body directly into a MongoDB query. You need to show the team both the vulnerability and the fix before the penetration tester does it for you during next week's audit.
// ❌ VULNERABLE — req.body passed directly into query
app.post('/login', async (req, res) => {
const user = await db.collection('users').findOne({
username: req.body.username, // attacker sends { $ne: null }
password: req.body.password // attacker sends { $gt: "" }
});
if (user) res.json({ token: generateToken(user) });
});
// ✅ SAFE — explicit type casting eliminates operator injection
app.post('/login', async (req, res) => {
// String() converts any object to a literal string
// { "$ne": "" } becomes "[object Object]" — harmless
const username = String(req.body.username);
const password = String(req.body.password);
// Query only by username — never include raw password in query
const user = await db.collection('users').findOne({ username });
// Compare hashed password in application code, not in the DB query
const valid = user && await bcrypt.compare(password, user.passwordHash);
if (valid) res.json({ token: generateToken(user) });
else res.status(401).json({ error: "Invalid credentials" });
});
Attack payload: { "username": "admin", "password": { "$ne": "" } }
VULNERABLE:
query → { username: "admin", password: { $ne: "" } }
Matches every user with any password.
Response: 200 OK + valid JWT 🚨
SAFE:
String({ "$ne": "" }) = "[object Object]"
query → { username: "admin" }
bcrypt.compare("[object Object]", hash) = false
Response: 401 Unauthorized ✓String(req.body.username)
JavaScript's String() converts any value to its string representation. An attacker-injected object like {"$ne":""} becomes the literal string "[object Object]". MongoDB will look for a username literally equal to that string — matching no real user, so the injection is harmless.
bcrypt.compare() in application code
Never store plain-text passwords and never compare them inside a MongoDB query. bcrypt.compare() runs the hash check in application code — keeping the database query simple (find by username only) and removing the password field from the query entirely. Even if an operator sneaks in, there is no password column left to attack.
javascriptEnabled: false (from Layer 1)
This is defence in depth. Even if a $where operator somehow reaches your MongoDB instance, server-side JavaScript execution is disabled at the config level — so there is nothing for the operator to run. Two independent controls prevent the same class of attack.
Layer 4 — Encryption at Rest and Audit Logging
TLS encrypts the wire. Encryption at rest protects the disk — so if a server is ever physically stolen or a storage volume is snapshotted by someone with cloud console access, they get encrypted blobs rather than your users' personal data. Audit logging is what lets you prove, after the fact, exactly who did what and when.
The scenario: Your company is pursuing SOC 2 Type II certification. The auditors need evidence that all customer PII is encrypted at rest, and that every administrative database action is captured in a tamper-evident log that feeds into your SIEM. You are configuring MongoDB Enterprise to satisfy both requirements simultaneously.
security:
enableEncryption: true # AES-256 for all data files on disk
encryptionKeyFile: /etc/mongo.key # local key — use KMS in real production
auditLog:
destination: file # write to file (not syslog)
format: JSON # machine-parseable for SIEM ingestion
path: /var/log/mongodb/audit.json
filter: '{ atype: { $in: [ # capture only high-value events
"authenticate",
"createUser",
"dropUser",
"createCollection",
"dropCollection",
"shutdown"
] } }'
// Sample entries written to audit.json
{"atype":"authenticate","ts":{"$date":"2025-03-10T09:22:11Z"},
"users":[{"user":"payments_svc","db":"payments"}],"result":0}
{"atype":"createUser","ts":{"$date":"2025-03-10T09:25:44Z"},
"users":[{"user":"analytics_svc","db":"payments"}],
"param":{"roles":[{"role":"read","db":"payments"}]},"result":0}
{"atype":"dropCollection","ts":{"$date":"2025-03-10T09:31:19Z"},
"param":{"ns":"payments.transactions"},"result":0}
⚠️ ALERT: Unexpected collection drop — SIEM alert firedenableEncryption: true
Activates MongoDB's WiredTiger Encrypted Storage Engine. Every data file, journal entry, and temporary file written to disk is encrypted with AES-256-CBC. Reading the raw files without the key returns garbage. The encryption is transparent to the application — no query changes required.
encryptionKeyFile — local key vs KMS
A local key file is fine for this example. In production, use a KMIP-compliant key management service — HashiCorp Vault, AWS KMS, or Azure Key Vault — so the encryption key never sits on the same physical machine as the data it protects. Storing the key and the lock in the same drawer defeats the purpose.
auditLog filter — why not log everything?
Without a filter, MongoDB logs every single query — gigabytes per hour on a busy cluster, which overwhelms your SIEM and buries the signal in noise. The filter captures only the high-value security events: logins, user management changes, and destructive schema operations. That is what a SOC 2 auditor needs to see, and what your on-call engineer needs to alert on.
Teacher's Note
The 2017 MongoDB ransomware wave required zero exploits. Attackers scanned port 27017, connected without credentials, dropped every database, and left a ransom note — all automated, all within hours. Security in NoSQL is not about being clever. It is about not leaving the door unlocked: bind to private IPs, enable auth, enforce TLS, apply least privilege, and log everything that matters. Get those five things right and you have eliminated the overwhelming majority of real-world NoSQL breaches.
Practice Questions — You're the Engineer
Scenario:
findOne() query. During an internal security review, a colleague sends the payload {"username":"admin","password":{"$ne":""}} and receives a valid session token back — without knowing the admin password. The attacker used a MongoDB query operator as the password value to match every document in the collection. What category of attack is this?
Scenario:
root role six months ago because it was quick to set up during a hackathon and nobody changed it. The service is compromised through a dependency vulnerability, and within minutes the attacker has dropped your entire payments database and exported your user collection. A post-mortem identifies the root cause: the analytics service had far more access than it needed to do its job. What security principle was violated?
Scenario:
$where operator. They crafted a query that executed arbitrary JavaScript on the database server and exfiltrated collection names. You want to eliminate this entire attack surface at the configuration level — regardless of what operators reach the server from the application layer. What single setting in mongod.conf under the security block disables server-side JavaScript execution?
Quiz — NoSQL Security in Production
Scenario:
mongo --host <public-ip> with no username or password and successfully run show dbs. You inspect mongod.conf and find bindIp: 0.0.0.0 with no security block present. Which option correctly identifies both configuration failures causing this?
Scenario:
Too many open files errors. You review the handler code and find that every incoming HTTP request calls MongoClient("mongodb://..."), runs the query, then calls client.close(). There are 30 pods each receiving 100 requests per second. What is the correct fix?
Scenario:
/etc/mongo.key on the same EC2 instance as the database. Your CISO flags this as non-compliant with PCI-DSS, which requires that encryption keys be stored separately from the data they protect. An attacker who gains root access to the EC2 instance currently has both the encrypted data files and the key to decrypt them. What is the correct remediation?
Up Next · Lesson 35
Backup & Recovery
Snapshots, mongodump, point-in-time recovery — because a backup you have never tested is not a backup.