MongoDB Security and Operations: Authentication, Roles, TLS, Queryable Encryption, Backup and Monitoring
The application's MongoDB user can drop collections even with readWrite; for least privilege, define a custom role that grants only the actions needed. With real output: the 25 actions of readWrite, a custom role that rejects drop and createIndex, TLS that requires a CA, an equality query on an encrypted field with explicit Queryable Encryption on Community, mongodump with a restore test, a filtered profiler, $queryStats with 1% sampling and the driver's OpenTelemetry spans.
Version note: The examples in this post were run with MongoDB 9.0.8 Community Server, mongosh 2.6, MongoDB Database Tools 100.20, .NET 10 and MongoDB.Driver 3.14. The scripts were also run on MongoDB 8.0.38; the differences are in the relevant sections.
Guess First
In post 2 we created the application user of Bookstore, the online bookstore used as the shared example across this site's series, with only the readWrite role on the bookstore database, and saw that it could not reach other databases. Let's use the same user to make a copy of the books and try to drop the copy:
// 14 - guess first: what can the application user do besides reading and writing documents?
db.books.aggregate([{ $match: {} }, { $out: "books_copy" }]);
print("books_copy documents:", db.books_copy.countDocuments());
print("drop() as bookstore_app:", db.books_copy.drop());
Is "read and write" permission enough to drop a collection?
books_copy documents: 77
drop() as bookstore_app: true
It is. Beyond reading and writing documents, readWrite includes dropping collections, creating and removing indexes and renaming collections. A NoSQL injection hole in the application, the cousin of SQL injection, or a stolen connection string can take all the orders with db.orders.drop(). This post tries the basic tools for protecting and operating MongoDB on Bookstore: roles and least privilege, the network and TLS, Queryable Encryption, backup with a restore test, and monitoring. The scripts run in post 2's lab; they start with 02-load.sh and 06-bookstore-indexes.js and undo what they do at the end.
Authentication and Roles: Least Privilege
For authentication MongoDB uses SCRAM by default; authorization is role based. The built-in roles are coarse-grained: read, readWrite, dbAdmin, userAdmin, clusterMonitor, backup, restore, root. 14-roles.js first asks the server what readWrite really contains:
readWrite: 25 actions on every collection, among them: convertToCapped, createCollection, createIndex, createSearchIndexes, dropCollection, dropIndex, dropSearchIndex, renameCollectionSameDB
A running API needs none of these. A custom role grants actions per collection:
// The running API needs documents, not schema changes: no drop, no index or collection management
bookstore.createRole({
role: "bookstoreApi",
privileges: [
{ resource: { db: "bookstore", collection: "books" }, actions: ["find", "update"] },
{ resource: { db: "bookstore", collection: "orders" }, actions: ["find", "insert", "update"] },
{ resource: { db: "bookstore", collection: "reviews" }, actions: ["find", "insert", "update", "remove"] },
{ resource: { db: "bookstore", collection: "carts" }, actions: ["find", "insert", "update", "remove"] },
{ resource: { db: "bookstore", collection: "customers" }, actions: ["find", "insert", "update"] },
{ resource: { db: "bookstore", collection: "categories" }, actions: ["find"] },
{ resource: { db: "bookstore", collection: "authors" }, actions: ["find"] }
],
roles: []
});
bookstore.createUser({ user: "bookstore_api", pwd: process.env.BOOKSTORE_API_PASSWORD || passwordPrompt(), roles: ["bookstoreApi"] });
find a book: {"title":"Dune"}
decrement the stock: 1
delete a book: Unauthorized: not authorized
drop orders: Unauthorized: not authorized
create an index: Unauthorized: not authorized
read another database: Unauthorized: not authorized
The API can read books and decrement stock; it cannot delete books, drop collections or create indexes. There is no remove on orders: the archive pattern from post 5 moves orders instead of deleting them, and that job belongs to the user of a separate scheduled job. So there are two users: bookstore_api for the running application, and bookstore_app with readWrite for deploys and migrations (02-load.sh's mongoimport --drop and post 6's indexes need it). The migration user's password does not live in the application's environment; it is used only in the deploy pipeline. For reports, a separate user with the built-in read role and the hidden member from post 13 are enough. A warning: as the action list grows, the role gets harder to maintain; make updating the role part of the deploy step when you add a new collection.
The Network: bindIp and TLS
MongoDB binaries listen only on localhost by default. The Docker image, however, listens on all interfaces inside the container; the port mapping decides what is exposed to the outside. Post 2's compose.yaml published the port only on 127.0.0.1, and auth was on. What happens when one of the two is missing is well known: in the past there were waves of attacks in which the data on MongoDB servers without auth and open to the internet was deleted in bulk and a ransom note was left. The lesson is simple: auth always on, the port only on the network that needs it.
For traffic on the network there is TLS. 14-tls.sh creates a lab CA and a server certificate for localhost inside the lab container, starts a second, temporary mongod that requires TLS on port 27040, and tries three connections:
mongod --port 27040 --dbpath "$dir/db" --tlsMode requireTLS --tlsCertificateKeyFile server.pem --tlsCAFile ca.pem \
--tlsAllowConnectionsWithoutCertificates --logpath "$dir/mongod.log" --fork > /dev/null
--- without TLS
MongoServerSelectionError: connection <monitor> to 127.0.0.1:27040 closed
--- with TLS, without trusting the lab CA
MongoNetworkError: self-signed certificate in certificate chain
--- with TLS and the lab CA
hello ok: 1
{
'1.0': Long('0'),
'1.1': Long('0'),
'1.2': Long('0'),
'1.3': Long('4'),
unknown: Long('0')
}
The server closed the connection without TLS; the client with TLS but without trust in the CA rejected the certificate; with the right CA the connection was made over TLS 1.3. The first attempt of the script taught two lessons. A mongod started without --tlsCAFile did not come up and stopped with an error saying that using TLS without specifying a chain of trust is no longer supported. And once --tlsCAFile was given, the server started asking clients for certificates; connecting without a client certificate needed --tlsAllowConnectionsWithoutCertificates. Requiring client certificates is the basis of x.509 authentication; for replica set members to authenticate each other, x.509 certificates are recommended in production instead of the lab's keyfile. A line in the log also says that TLS 1.0 and 1.1 are disabled automatically. On the .NET side, adding tls=true to the connection string is enough; on Windows the CA must be trusted in the certificate store.
Queryable Encryption: Queries on an Encrypted Field
TLS protects traffic, disk encryption protects the disk; but a database administrator, a backup file or someone who breaks into the server sees the data in the clear. In-use encryption is for this gap: the field is encrypted in the client and the server only sees encrypted bytes. There are two ways: CSFLE, and its successor, Queryable Encryption. Queryable Encryption allows equality and range queries on an encrypted field; according to the 9.0 release notes, prefix, suffix and substring queries became GA with 9.0 as well. The compatibility table matters: automatic encryption is in Enterprise and Atlas, while Community only has explicit encryption, where the application encrypts the field itself. 14-qe.js tries the explicit path on Community:
const localKey = crypto.randomBytes(96); // lab only: a real deployment keeps the master key in a KMS
const encrypted = Mongo(db.getMongo()._uri, {
keyVaultNamespace: "encryption.__keyVault",
kmsProviders: { local: { key: localKey } },
bypassQueryAnalysis: true
});
const clientEncryption = encrypted.getClientEncryption();
clientEncryption.createEncryptedCollection("bookstore", "customers_secure", {
provider: "local",
createCollectionOptions: { encryptedFields: { fields: [{ path: "phone", bsonType: "string", queries: { queryType: "equality", contention: 0 } }] } }
});
// Equality query on the encrypted field: the query value is encrypted too
const query = clientEncryption.encrypt(keyId, target, { algorithm: "Indexed", contentionFactor: 0, queryType: "equality" });
const found = secure.findOne({ phone: query });
found through the encrypted client: Linus Torvalds +90 555 017 95 53
raw phone field: Binary subtype 6 | 226 bytes
other fields in the raw document: _id, name, phone, __safeContent__
plain-text search for the phone: 0 documents
The encrypting client found the customer by phone number and decrypted the result automatically. Someone looking at the same document through a normal connection sees a 226-byte Binary (subtype 6, encrypted data) and the __safeContent__ field the server keeps for queries; a plain-text search returns zero results. I set contention to 0: in the first attempt, when a query with 0 was sent to a collection created with the default of 8, the server returned "max contention 0 ... does not match collection's configured contention 8"; the value must be the same on both sides. The 96-byte local key in the lab is for experiments only; in production the master key lives in a KMS. Deleting the key makes the data unreadable for good; that is why a key per customer is an option for deletion requests under data protection laws. A version note: mongocryptd was deprecated with 9.0; for automatic encryption, the Automatic Encryption Shared Library, which needs no separate process, takes its place. The Entity Framework Core provider has a Queryable Encryption configuration too; add it to the scope of post 11.
Backup and a Restore Test
The documentation's comparison lists three self-managed ways: mongodump/mongorestore (for small deployments, a logical copy), filesystem snapshots (on condition that the journal is on the same volume, fast but laborious on a sharded cluster) and Ops Manager/Cloud Manager (including point-in-time restore). Atlas's own backup supports point in time as well; I did not run it because it requires an Atlas account. Whichever you choose, a backup that has never been restored is a hope. 14-backup.sh takes a dump, restores it under another database name and compares the numbers:
mongodump "${auth[@]}" --oplog --gzip --archive="$archive" 2>&1 | grep oplog
mongorestore "${auth[@]}" --gzip --archive="$archive" \
--nsInclude 'bookstore.*' --nsFrom 'bookstore.*' --nsTo 'bookstore_restore.*' 2>&1 | tail -1
2026-11-14T14:55:18.906+0000 writing captured oplog to ``
2026-11-14T14:55:18.907+0000 dumped 1 oplog entry
archive size: 40K
2026-11-14T14:55:19.186+0000 759 document(s) restored successfully. 0 document(s) failed to restore.
books documents 77 -> 77 | indexes 3 -> 3
orders documents 379 -> 379 | indexes 4 -> 4
reviews documents 181 -> 181 | indexes 2 -> 2
customers documents 40 -> 40 | indexes 1 -> 1
--oplog also captures the writes that arrive while the dump runs (1 entry here); when restoring under the same names, applying it with --oplogReplay makes the dump consistent as of a single point in time. --oplog only works with a dump of the whole instance, not of a single database. The restore test also counted the indexes; had post 6's indexes not come back, the first queries after the restore would have fallen back to COLLSCAN. Tie this test to a scheduled job and measure its duration: on a growing database mongorestore can take hours, and that duration becomes your real RTO.
Monitoring: the Profiler, the Slow Query Log, $queryStats and Driver Tracing
// Profile only operations that scan a whole collection
bookstore.setProfilingLevel(1, { filter: { planSummary: "COLLSCAN" } });
profiled: query bookstore.books COLLSCAN docsExamined 77 nreturned 22
slow query log: COLLSCAN docsExamined 77 durationMillis 0
WiredTiger cache: 5 MB used of 3406 MB
$queryStats: count bookstore.customers execCount 1
$queryStats: aggregate local.oplog.rs execCount 1
sample rate: reads 0.01 writes 0.01
I gave the profiler a filter instead of a duration: only operations that scan a whole collection. The script ran two queries; post 3's $regex search (a COLLSCAN that read 77 documents and returned 22) was recorded, the indexed customer query was not. The same filter also decides the slow query log: the query reached the log although it took 0 ms. In production, turn the profiler on briefly and with a filter; recording every operation is a load in itself. The WiredTiger cache line is the live counterpart of the cache formula from post 1. In 9.0, $queryStats is on by default but samples only 1% of reads and writes; that is why our two queries are not in the list; there is a count from the backup script and one of the server's own internal operations, and which ones show up changes from run to run. It is good for seeing query shapes statistically over time, not for catching a single slow query. The difference in 8.0 is below.
To see a running operation, use db.currentOp({ appName: "Bookstore.MongoLab", secs_running: { $gte: 5 } }) and, if needed, db.killOp(opid); this is where setting ApplicationName in post 9 pays off. On the application side, the driver has produced OpenTelemetry-compatible spans since 3.7: attaching a listener to the ActivitySource named MongoDB.Driver is enough, the OpenTelemetry SDK is not required:
// The driver (3.7+) creates spans on the "MongoDB.Driver" ActivitySource; any listener can collect them
using ActivityListener listener = new()
{
ShouldListenTo = source => source.Name == MongoTelemetry.ActivitySourceName,
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
find 2.2 ms db.system.name=mongodb, db.command.name=find, db.namespace=bookstore, db.query.summary=find bookstore.books, db.collection.name=books, server.address=localhost, network.transport=tcp
find bookstore.books 55.9 ms db.system.name=mongodb, db.operation.name=find, db.operation.summary=find bookstore.books, db.namespace=bookstore, db.collection.name=books
found Dune
There are two spans: the outer operation (56 ms including connection setup and server selection, because it is the first request) and the inner command (the find sent to the server, 2 ms). The query text is not written to the span by default; it can be enabled with TracingOptions.QueryTextMaxLength, but think before enabling it so that customer data does not leak into traces. If OpenTelemetry is set up in ASP.NET Core, the line AddSource(MongoTelemetry.ActivitySourceName) ties MongoDB calls to the trace of the HTTP requests.
Differences in 8.0
All scripts also ran on 8.0.38 and gave the same result; there are three differences. In 8.0 readWrite contains 24 actions; the 25th action in 9.0 is performRawDataOperations. In 8.0 $queryStats collects nothing by default: internalQueryStatsRateLimit is 0, the query returned zero query shapes, and there is no sampling parameter for write commands; in 9.0 sampling is on by default for both reads and writes. The deprecation of mongocryptd also belongs to 9.0. What stayed the same is worth noting too: the error for starting TLS without a chain of trust came with the same text on 8.0.38, and explicit Queryable Encryption and the backup/restore test gave exactly the same output on 8.0.
In Production
Manage user and role changes in code. Keep scripts like 14-roles.js in the repo and run them on deploy; a root granted by hand stays for years without anybody noticing. Keep passwords in a secret store and rotate them regularly; plan the change in the same deploy in which the application picks up the new password and restarts.
Four numbers for monitoring: replication lag (post 12), the oplog window (post 13), WiredTiger cache usage and the number of slow queries. Set an alert threshold for each; the profiler and $queryStats are for drilling down once the problem is found.
Common Pitfalls
Connecting the application as root. "Guess First" showed that even readWrite is too much; root opens all databases, users and cluster settings. A single injection hole in the application code loses the whole server.
Taking a backup without the oplog and assuming it is consistent. During a mongodump that takes a few minutes, orders and stock are read at different moments; in data restored without --oplog, the order may be there while the stock was never decremented. Never trying a restore is just as dangerous.
A Small Experiment
★☆☆ Create a user called bookstore_reports with the built-in read role for reporting. Repeat the attempts from 14-roles.js with it; what error does the stock decrement line give?
★★☆ Add the customer's e-mail address to 14-qe.js as a second encrypted field and query by both phone and e-mail. Raise contention to 8; how does the size of the enxcol_ collections created next to the encrypted collection change?
★★★ Run 14-backup.sh under a write load like failover during the dump and restore it under the same names with --oplogReplay into a new container. Are the stock and the orders consistent in the restored data?
In the next and last post we look at versions: MongoDB's new release model, the changes from 6.0 to 9.0, moving from C# driver 2.x to 3.x, and an upgrade checklist.