Security and Atlas
A fresh mongod started with default settings has no authentication. On your laptop, bound to localhost, that is harmless; on a server with a public IP it is a breach waiting to happen — and unsecured MongoDB instances have leaked enormous amounts of data exactly this way. Production security is a short stack of defenses, each closing a different door: where connections may come from, how they are protected, who you are, what you may do, and what is on disk.
Everything marked lab ran on this Mac on throwaway mongo:8.3.11 containers (mongodb-b3-sec, capped at 512 MB, removed afterwards; a throwaway CA and server certificate made with openssl, a random keyfile; nothing left the machine and no cloud account was involved). The course’s automated checker cannot run these fences (they need --auth or TLS servers), so their outputs are recorded from the real run. Passwords in this lesson are placeholders such as <your-password> — never paste real ones into a snippet. Python/Go/Rust tabs mirror the verified Node call and are not executed by the checker.
The layers of protection
Section titled “The layers of protection”flowchart LR Client["Application"] --> Net["Network gate — firewall, bind IP, VPC"] Net --> TLS["TLS — encrypts the connection"] TLS --> Auth["Authentication — who are you"] Auth --> RBAC["Authorization — what may you do"] RBAC --> Data["Data — encrypted at rest on disk"]
No single layer is sufficient. Authentication without network limits still invites brute force; a locked-down network without auth trusts everyone inside it.
Step 0 — the default is open
Section titled “Step 0 — the default is open”The plain mongo:8.3.11 container, no flags (published only on the host’s loopback):
docker run -d --name mongo-open --memory 512m -p 127.0.0.1:27304:27017 mongo:8 mongod --wiredTigerCacheSizeGB 0.25docker exec mongo-open mongosh --quiet --file /tmp/open.js # the script below// mongosh/sec-open.js @skip-verify lab: needs a plain throwaway mongod (real output below)console.log(db.adminCommand({ listDatabases: 1 }).databases.map((d) => d.name));db.getSiblingDB("library").members.insertOne({ name: "anyone" });console.log("insert with no credentials: ok");console.log(db.serverCmdLineOpts().argv);[ 'admin', 'config', 'local' ]insert with no credentials: ok[ 'mongod', '--wiredTigerCacheSizeGB', '0.25', '--bind_ip_all' ]No credentials, and it listed databases and wrote data. Note the last line: the official image starts mongod with --bind_ip_all, unlike the bare binary, which — per the manual — “bind[s] to localhost by default” (MongoDB configuration hardening, consulted 2026-09-30). A container reachable on a published port is therefore reachable by anyone who can reach the port: publish it on 127.0.0.1 only, as above.
Authentication and roles (RBAC)
Section titled “Authentication and roles (RBAC)”Authentication proves who you are; role-based access control limits what you may do. The principle is least privilege. The image can create the first (root) user for you from environment variables, on an empty data directory only, and start with --auth:
docker run -d --name mongo-auth --memory 512m -p 127.0.0.1:27304:27017 \ -e MONGO_INITDB_ROOT_USERNAME=root -e MONGO_INITDB_ROOT_PASSWORD='<your-root-password>' \ mongo:8 mongod --auth --wiredTigerCacheSizeGB 0.25Now an anonymous client. ping still works, everything else is refused with the exact message every operator learns to recognise:
// mongosh/sec-anonymous.js @skip-verify lab: needs the --auth container (real output below)console.log(db.runCommand({ ping: 1 }));for (const op of [() => db.getSiblingDB("library").members.find().toArray(), () => db.adminCommand({ listDatabases: 1 })]) { try { op(); } catch (e) { console.log(e.name, e.codeName, e.code, "-", e.message); }}{ ok: 1 }MongoServerError Unauthorized 13 - Command find requires authenticationMongoServerError Unauthorized 13 - Command listDatabases requires authenticationCreate least-privilege users (as root; the password comes from an environment variable — passwordPrompt() works only in an interactive shell, so it must not appear in a script), then show what a read-only user may not do:
// mongosh/sec-users.js @skip-verify lab: run as root against the --auth container (real output below)const admin = db.getSiblingDB("admin");admin.createUser({ user: "library_app", pwd: process.env.APP_PW, roles: [{ role: "readWrite", db: "library" }] });admin.createUser({ user: "library_reader", pwd: process.env.APP_PW, roles: [{ role: "read", db: "library" }] });console.log(admin.getUsers().users.filter((u) => u.user.startsWith("library_")).map((u) => ({ user: u.user, roles: u.roles })));
const app = new Mongo("mongodb://library_app:" + process.env.APP_PW + "@localhost:27017/library?authSource=admin");app.getDB("library").members.insertOne({ name: "Ada" });const reader = new Mongo("mongodb://library_reader:" + process.env.APP_PW + "@localhost:27017/library?authSource=admin");console.log("reader can read:", reader.getDB("library").members.countDocuments());try { reader.getDB("library").members.insertOne({ name: "x" }); } catch (e) { console.log(e.codeName, e.code, "-", e.message); }[ { user: 'library_app', roles: [ { role: 'readWrite', db: 'library' } ] }, { user: 'library_reader', roles: [ { role: 'read', db: 'library' } ] }]reader can read: 1Unauthorized 13 - not authorized on library to execute command { insert: "members", documents: [ { name: "x", _id: ObjectId('6abcd5da1041bb9cb750d769') } ], ordered: true, lsid: { id: UUID("8927124f-ce75-486b-b03c-c18d8c4052ed") }, $db: "library" }Drivers send credentials in the connection string. The database in the URI path is not where the user lives: without authSource, the driver authenticates against the database named in the path, and these users live in admin.
mongosh "mongodb://library_app@localhost:27017/library?authSource=admin" --password "$APP_PW"// app/sec-connect.mjs @skip-verify lab: needs the --auth container on 127.0.0.1:27304import { MongoClient } from "mongodb";
const uri = `mongodb://library_app:${process.env.APP_PW}@127.0.0.1:27304/library?authSource=admin`;const client = new MongoClient(uri);await client.connect();console.log(await client.db("library").collection("members").countDocuments());await client.close();uri = "mongodb://library_app:<your-password>@host:27017/library?authSource=admin&tls=true"client = MongoClient(uri)uri := "mongodb://library_app:<your-password>@host:27017/library?authSource=admin&tls=true"client, err := mongo.Connect(options.Client().ApplyURI(uri))if err != nil { return err}let uri = "mongodb://library_app:<your-password>@host:27017/library?authSource=admin&tls=true";let client = Client::with_uri_str(uri).await?;Keyfile, TLS and mongod.conf
Section titled “Keyfile, TLS and mongod.conf”Three more pieces, put together in one config file and started for real. TLS encrypts the connection (and, with a CA file, lets the client verify the server); a keyfile is the shared secret replica-set members use to authenticate each other (it also turns on authorization); net.bindIp decides which interfaces mongod listens on. The throwaway certificate authority, server certificate (valid for localhost and 127.0.0.1) and keyfile were generated like this — the files themselves never go in a repository:
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.pem -days 2 -subj "/CN=lab-ca"openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj "/CN=localhost"printf "subjectAltName=DNS:localhost,IP:127.0.0.1" > san.extopenssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out server.crt -days 2 -extfile san.extcat server.key server.crt > server.pemopenssl rand -base64 756 > keyfile# mongod.confsecurity: authorization: enabled keyFile: /etc/mongo-lab/keyfilenet: port: 27017 bindIp: 0.0.0.0 tls: mode: requireTLS certificateKeyFile: /etc/mongo-lab/server.pem CAFile: /etc/mongo-lab/ca.pem allowConnectionsWithoutCertificates: truereplication: replSetName: rs0storage: wiredTiger: engineConfig: cacheSizeGB: 0.25allowConnectionsWithoutCertificates: true matters: once a CAFile is set, mongod also asks clients for a certificate (mutual TLS); without this line a client that presents none is dropped, and in the lab it looked exactly like the plain-TCP failure below (connection ... closed). bindIp: 0.0.0.0 is right only inside a container, where the published port (loopback on the host) is the real gate; on a host you would list private addresses, never 0.0.0.0 on a public interface. The keyfile must be owned by the user mongod runs as with no group/other access. Starting with a world-readable keyfile fails on the spot (real log line):
docker run --rm --entrypoint bash -v "$PWD/certs:/certs:ro" mongo:8 -c \ 'mkdir /etc/mongo-lab && cp /certs/* /etc/mongo-lab/ && chmod 444 /etc/mongo-lab/keyfile && exec gosu mongodb mongod --config /etc/mongo-lab/mongod.conf'{"c":"ACCESS","id":20254,"msg":"Read security file failed","attr":{"error":{"code":30,"codeName":"InvalidPath","errmsg":"permissions on /etc/mongo-lab/keyfile are too open"}}}{"s":"F","c":"CONTROL","id":20575,"msg":"Error creating service context","attr":{"error":"Location5579201: Unable to acquire security key[s]"}}With correct permissions (chown mongodb, chmod 400) the server starts. The first user is created through the localhost exception, after rs.initiate():
docker run -d --name mongo-secure --hostname sec-host --memory 512m -p 127.0.0.1:27305:27017 \ --entrypoint bash -v "$PWD/certs:/certs:ro" mongo:8 -c \ 'mkdir /etc/mongo-lab && cp /certs/* /etc/mongo-lab/ && chown -R mongodb:mongodb /etc/mongo-lab && chmod 400 /etc/mongo-lab/* && exec gosu mongodb mongod --config /etc/mongo-lab/mongod.conf'URI="mongodb://localhost/?tls=true&tlsCAFile=/etc/mongo-lab/ca.pem&directConnection=true"docker exec mongo-secure mongosh --quiet "$URI" --eval 'rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })'docker exec -e ROOT_PW='<your-root-password>' mongo-secure mongosh --quiet "$URI" --eval 'db.getSiblingDB("admin").createUser({ user: "root", pwd: process.env.ROOT_PW, roles: ["root"] })'Then the things you will meet in production, in order: (1) plain TCP to a requireTLS server, (2) TLS with the CA but no credentials, (3) TLS plus credentials, (4) a name the certificate does not cover, and (5) what the config file actually produced (real output, run against the container above):
M="docker exec -e ROOT_PW=<your-root-password> mongo-secure mongosh --quiet"CA="tlsCAFile=/etc/mongo-lab/ca.pem"echo "== 1) plain TCP to a requireTLS server"$M "mongodb://localhost/?directConnection=true&serverSelectionTimeoutMS=3000" --eval 'db.hello().ok' 2>&1 | tail -2echo "== 2) TLS, anonymous"$M "mongodb://localhost/?tls=true&$CA&directConnection=true" --eval 'try { db.getSiblingDB("library").members.find().toArray() } catch (e) { console.log(e.codeName, e.code, "-", e.message) }'echo "== 3) TLS + credentials"$M "mongodb://root:<your-root-password>@localhost/?tls=true&$CA&authSource=admin&directConnection=true" --eval 'console.log("authenticated as", db.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUsers[0].user)'echo "== 4) a name that is not in the certificate"$M "mongodb://sec-host/?tls=true&$CA&directConnection=true&serverSelectionTimeoutMS=3000" --eval 'db.hello().ok' 2>&1 | tail -2echo "== 4b) same, verification switched off"$M "mongodb://sec-host/?tls=true&$CA&directConnection=true&tlsAllowInvalidHostnames=true" --eval 'console.log("connected:", db.hello().ok === 1)' 2>&1 | tail -1echo "== 5) what the config file produced"$M "mongodb://root:<your-root-password>@localhost/?tls=true&$CA&authSource=admin&directConnection=true" --eval 'const p = db.serverCmdLineOpts().parsed; printjson({ security: p.security, net: p.net, replication: p.replication })'== 1) plain TCP to a requireTLS serverMongoServerSelectionError: connection <monitor> to 127.0.0.1:27017 closed== 2) TLS, anonymousUnauthorized 13 - Command find requires authentication== 3) TLS + credentialsauthenticated as root== 4) name not in the certificateMongoServerSelectionError: Hostname/IP does not match certificate's altnames: Host: sec-host. is not in the cert's altnames: DNS:localhost, IP Address:127.0.0.1== 4b) same, verification switched offconnected: true== 5) what the config file produced{ security: { authorization: 'enabled', keyFile: '/etc/mongo-lab/keyfile' }, net: { bindIp: '0.0.0.0', port: 27017, tls: { CAFile: '/etc/mongo-lab/ca.pem', allowConnectionsWithoutCertificates: true, certificateKeyFile: '/etc/mongo-lab/server.pem', mode: 'requireTLS' } }, replication: { replSetName: 'rs0' }}Read these carefully. A non-TLS client is dropped before it can say anything; a TLS client without credentials gets the same requires authentication error as before; the hostname check works — and tlsAllowInvalidHostnames (or tlsAllowInvalidCertificates) makes the error disappear precisely by disabling the protection, so it is a diagnostic to remove, not a fix to keep.
Encryption at rest, network and Atlas
Section titled “Encryption at rest, network and Atlas”TLS protects data in transit; encryption at rest protects the files on disk. In self-managed MongoDB, the encrypted storage engine “is available in MongoDB Enterprise only” (Encryption at rest, consulted 2026-09-30), so on the Community server the alternatives are disk- or filesystem-level encryption provided by your platform. In the cloud, wrap the server in a private network (VPC) and use a firewall or security group so only your application servers reach the port.
MongoDB Atlas is the managed service, and its documentation makes these commitments (consulted 2026-09-30): “Atlas requires TLS to encrypt the connections to your databases” (Set up cluster security); “Atlas only allows client connections to the cluster from entries in the project’s IP access list” (IP access list); database users must be created to connect; and encryption at rest is enabled on its clusters (Encryption at rest — stated for Atlas Infinite clusters). This course did not run anything on Atlas (no account); everything in this paragraph is documentation, not measurement.
In Compass / Atlas: you connect Compass with a connection string copied from the Atlas UI (which already carries TLS); in Atlas, Database Access manages users and roles and Network Access manages the IP access list — the same layers as forms.
Tips and gotchas
Section titled “Tips and gotchas”- Never expose port 27017 without authentication. The open container above took inserts from anyone who could reach it.
- Least privilege. The read-only user could read and was refused the insert; give each service its own user.
- Put
authSource=adminin the URI when the user lives inadmin. - Do not paste
tlsAllowInvalidCertificates=trueto make an error go away — it turns off the check that stops impostors. - TLS is in transit, encryption at rest is on disk; you need both. The keyfile is for member-to-member authentication.
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant, told the app user was created “in the admin database”, writes the obvious connection string:
🐛 Spot the bug
// AI comment: "credentials are in the URI, so this authenticates as library_app"const client = new MongoClient("mongodb://library_app:" + pw + "@127.0.0.1:27304/library");await client.connect();await client.db("library").collection("members").findOne();Show the answer
Without authSource, the driver authenticates against the database in the URI path (library), where library_app does not exist. Real run against the --auth container:
// app/sec-authsource.mjs @skip-verify lab: needs the --auth container on 127.0.0.1:27304 (real output below)import { MongoClient } from "mongodb";const pw = process.env.APP_PW;for (const [label, query] of [["bug (no authSource)", ""], ["fix (authSource=admin)", "?authSource=admin"]]) { const client = new MongoClient(`mongodb://library_app:${pw}@127.0.0.1:27304/library${query}`); try { await client.connect(); console.log(label, "->", await client.db("library").collection("members").countDocuments()); } catch (e) { console.log(label, "->", e.name, e.codeName, e.code, "-", e.message); } await client.close();}bug (no authSource) -> MongoServerError AuthenticationFailed 18 - Authentication failed.fix (authSource=admin) -> 1Mental Model
Section titled “Mental Model”Security is a row of independent doors. The network decides who can knock; TLS decides whether the knock can be overheard or faked; authentication decides who you are; roles decide what that identity may touch; encryption at rest decides what a stolen disk is worth. Every failure above was one door closing correctly — and each “fix” that opens it again (--bind_ip_all on a public port, tlsAllowInvalid*, root for every service) removes a door rather than solving a problem.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Self-hosted MongoDB | Full control of infrastructure and configuration | You own certificates and rotation, keyfiles, users, patching, backups and audits; encryption at rest needs Enterprise or the platform |
| MongoDB Atlas | TLS, authentication and an IP access list are documented defaults; less operational work | Less low-level control; usage-based cost; not measured here |
| One user per service and environment | A leak affects one service | More users to manage |
requireTLS with a private CA | Clients verify the server; plain connections dropped | Certificate issuance and rotation to run |
Common Misconceptions
Section titled “Common Misconceptions”- “A default
mongodis safe because it needs a password.” — the open container listed databases and accepted an insert without credentials. - “The database in the URI is where the user is looked up.” — it is the default
authSource; users created inadminneed?authSource=admin(error code 18 otherwise). - “
passwordPrompt()belongs in a script.” — it only works in an interactive shell; scripts read the secret from the environment or a secret manager. - “TLS means my data is encrypted.” — in transit only; disk needs encryption at rest, an Enterprise feature for self-managed MongoDB.
- “
tlsAllowInvalidHostnamesfixes the certificate error.” — it disables the very check that failed. - “The official image binds to localhost like the binary.” — it starts with
--bind_ip_all; publish ports on127.0.0.1or firewall them.
Python/Go/Rust tabs mirror the verified shell/Node behaviour; they are not executed by the course’s automated checker.