
MongoDB still ships with access control off until you turn it on. That is a bind-and-auth problem, not a query problem.
A default install that listens on a public interface with no user will be found. Operator injection is a second, separate bug: a JSON object where you expected a string.
The usual mistake is enabling auth and then spreading req.body into findOne so $ne becomes the password check.
This page is the deployment defaults to change first, then the query habits that keep a login from becoming a filter.
MongoDB 8.0 still ships with access control off. v8.0 authorization page on 22 August 2026. The first sentence is the product: you enable it with --auth or security.authorization. A default mongod that you then publish on 27017 is a database anyone on that network can read. The 2017 ransom wave was that default plus a public bind. The default did not change for 8.0.
The config is four lines in mongod.conf, a role that cannot drop the catalog, and a typed filter in the app. Pair this page with injection for the parser, with input validation for the rest of the body, and with the Ubuntu host guide for who may reach 27017.
MongoDB 8.0 still ships with authorization off. The on state is a bind you can name and asString on the filter, not findOne(req.body).
SecureCoding
Authorization is still off until you set it
0 manual. mongod will accept reads and writes with no user until security.authorization is enabled. Internal authentication between replica-set members also turns client authorization on. Atlas and other hosted products enable it for you. A tarball, a distro package, and the official Docker image do not.
Create the first user through the localhost exception, from a shell on the same machine, before you restart with auth on. If you enable auth with no user and then bind only a remote address, you lock yourself out. Keep 127.0.0.1 in bindIp through that restart.
# mongosh on the box, before the restart
use admin
db.createUser({
user: "adminUser",
pwd: passwordPrompt(),
roles: [ { role: "userAdminAnyDatabase", db: "admin" } ]
})
# /etc/mongod.conf
net:
bindIp: 127.0.0.1
port: 27017
security:
authorization: enabled
After the restart, a mongosh with no -u must fail on a read. A connection with adminUser must succeed. Then create appUser. Do not keep using adminUser from the app. LDAP authorization is deprecated in 8.0. that note on the same page. Do not start a new 8.0 cluster on LDAP. Use SCRAM-SHA-256 or the mechanism your hosted vendor documents.
The localhost exception exists so you can mint that first user. Once adminUser exists, turn the exception off so a process that can open 27017 on the box cannot mint a second god user.
# add under security: or setParameter:
setParameter:
enableLocalhostAuthBypass: false
A replica set needs internal auth as well as client auth. A keyFile or x509 cluster membership is the 8.0 form. Without it, a stranger who can reach the replica port can try to join the set. I am not walking that join. Set security.keyFile or security.clusterAuthMode: x509 before the set leaves a single node. Atlas does this for you. A self-managed three-node compose file does not.
Bind localhost, or private IP plus TLS
8.0 IP binding page. Binaries bind localhost by default. That default dies the moment a compose file uses ports: ["27017:27017"], because Docker publishes the container port on 0.0.0.0 on the host. robotmay’s comment is that setup. Pin the host side:
# compose: publish only on the host loopback
services:
mongo:
image: mongo:8.0
command: ["--auth", "--bind_ip", "127.0.0.1"]
ports:
- "127.0.0.1:27017:27017"
volumes:
-./mongod.conf:/etc/mongod.conf:ro
If the app runs on another host, bind a private address you control, not 0.0.0.0, and require TLS. net.tls.mode: requireTLS plus a server cert you issued is the remote shape. net.bindIpAll: true is the opposite of this section. Do not set it.
# remote app host. private NIC only.
net:
bindIp: 10.8.0.10
port: 27017
tls:
mode: requireTLS
certificateKeyFile: /etc/mongo/mongo.pem
security:
authorization: enabled
The app URI then uses mongodb://appUser@10.8.0.10:27017/appDb?authSource=appDb&tls=true. Verify the server name. A URI with tls=false against that listener must fail. The Ubuntu page is the firewall around that NIC: deny 27017 from the world, allow it from the app net only.
Unix sockets are the other local path. If net.unixDomainSocket.enabled is on, the socket is another bind. Put it in a directory the app user can open and nobody else can. Do not leave /tmp/mongodb-27017.sock world-open on a shared host. If you do not need the socket, set net.unixDomainSocket.enabled: false and keep TCP on 127.0.0.1.
Cloud images still copy a bindIp: 0.0.0.0 from a 2016 blog. Grep every template you inherit. A security group that is “open to my VPC” is better than the whole internet and worse than a local bind plus a private NIC. Prefer the NIC you can name.
Never pass a user object as a filter
Mongo evaluates a query object. If a field you expected to be a string arrives as an object, operators such as $ne are grammar. That is not SQL concatenation. It is a parser handing structure to the driver. The full writeup, including the Express 5 query-string change, is the injection article. This page is the Mongo half: do not spread untrusted objects into find, findOne, updateOne, or deleteMany.
function asString(value, name) {
if (typeof value !== "string") {
const err = new Error(name + " must be a string");
err.status = 400;
throw err;
}
return value;
}
// FIX: typed fields only.
const email = asString(req.body.email, "email");
const password = asString(req.body.password, "password");
const user = await users.findOne({ email });
// compare a hash. do not put password in the filter
JSON bodies are the remaining door on Express 5. The default query parser is simple, so bracket keys stay literal keys. express.json() still gives you nested objects. A sanitizer that strips keys starting with $ is a backup, not the primary lock. Mongoose 6 added sanitizeFilter, which wraps nested $ keys in $eq. Turn it on if you use Mongoose. Keep asString anyway. A schema that casts to String on insert does not cast a filter you built by spreading req.query.
// mongoose 8
const mongoose = require("mongoose");
mongoose.set("sanitizeFilter", true);
const User = mongoose.model("User", new mongoose.Schema({
email: { type: String, required: true },
passwordHash: { type: String, required: true },
}));
// still type-check. sanitizeFilter is a belt.
const email = asString(req.body.email, "email");
const user = await User.findOne({ email }).setOptions({ sanitizeFilter: true });
Do not enable $where. Do not pass request text into eval or server-side JavaScript. Atlas and current 8.0 self-managed defaults already restrict that. Leave them restricted.
Updates have the same shape. updateOne({ email }, { $set: { name } }) is a typed filter plus an operator you wrote. updateOne(req.body.filter, req.body.update) hands the grammar back to the client. Build the $set object in your code from strings and numbers you already checked. The same rule applies to deleteMany: a missing filter in some drivers means every document. Always pass a typed _id or email you just validated.
const email = asString(req.body.email, "email");
const name = asString(req.body.name, "name");
await users.updateOne({ email }, { $set: { name } });
// BAD: await users.updateOne(req.body.filter, req.body.update)
// BAD: await users.deleteMany({})
Least privilege is a role, not a comment
Built-in roles that belong on an app are small. readWrite on appDb is the usual one. read is enough for a reporting replica. root, dbOwner, and userAdminAnyDatabase do not belong in the application URI. Create appUser after adminUser, on appDb.
use appDb
db.createUser({
user: "appUser",
pwd: passwordPrompt(),
roles: [ { role: "readWrite", db: "appDb" } ]
})
// Node driver. authSource must match where you created appUser.
const { MongoClient } = require("mongodb");
const uri = process.env.MONGO_URI;
// mongodb://appUser:***@127.0.0.1:27017/appDb?authSource=appDb
const client = new MongoClient(uri);
const users = client.db("appDb").collection("users");
A custom role is the next step when readWrite is still too wide. Grant find and insert on users, and deny dropCollection. 8.0 role-privilege reference for those action names. If you cannot name the actions the app needs, you are not ready to mint a custom role. Stay on readWrite for that database, and still keep it off admin.
MongoDB 8.0 was released on 2 October 2024. Queryable encryption and WiredTiger encryption at rest are optional belts for data on disk. They do not replace authorization, and they do not type-check a filter. Turn them on when you have a key-management story. Do not skip appUser because the volume is encrypted.
Log authentication failures and slow operations. systemLog.destination: file plus operationProfiling.mode: slowOp is enough to see a scan you did not mean to run. An audit log is the hosted product’s job on Atlas. On self-managed, enable auditing only after you know where the file rotates. A full disk is its own outage.
The connection string is part of the lock. authSource=appDb must match where you created appUser. A URI that points at admin with a user that has readWrite only on appDb will fail in a confusing way, and the “fix” people paste is to grant root. Fail the boot if MONGO_URI contains authSource=admin and the process is not a migrate job. Set retryWrites=true if you want driver retries. Do not set directConnection=true against a replica set you actually use, or you skip the set and land on one node with no auth story you intended.
Network compression and maxPoolSize are capacity knobs, not security. A pool of 200 on a tiny host is how one app process becomes a self-DoS. Keep the pool small, keep serverSelectionTimeoutMS finite, and fail the request when the set is gone. A hung checkout is an application-layer stall that looks like the database is “down” when the URI is wrong.
| Who | Role | Where it connects from |
|---|---|---|
| adminUser | userAdminAnyDatabase | localhost shell only |
| appUser | readWrite on appDb | the app process |
| reportUser | read on appDb | the warehouse job |
| root | do not mint | nowhere |
Rotate appUser in the same change as an employee leaving. Store the URI in the environment, not in the repo. The secure coding checklist is the rest of secret handling.
Prove auth, bind, and the type check
You are not walking a ransom against a stranger’s port. You are proving your process refused an anonymous read, listened where you said, and returned 400 when the email field was not a string.
db.adminCommand({ getCmdLineOpts: 1 })showsparsed.security.authorizationasenabledandparsed.net.bindIpas127.0.0.1or the private NIC.mongosh --quiet --eval 'db.getMongo()'without credentials fails after the restart.- A login handler test posts an object for
emailand expects 400, not a user document. ss -lntp | grep 27017on the host shows127.0.0.1:27017or the private address, not0.0.0.0:27017.
# on the box. Expect authorization enabled and a local bind.
mongosh --quiet -u adminUser -p --authenticationDatabase admin \
--eval 'db.adminCommand({ getCmdLineOpts: 1 }).parsed'
# Expect this to fail once auth is on.
mongosh --quiet --eval 'db.getSiblingDB("appDb").users.findOne()'
ss -lntp | grep 27017
// staging test against your app, not against a public scanner
const request = require("supertest");
const { app } = require("../app");
test("email must be a string", async () => {
const res = await request(app)
.post("/login")
.send({ email: { not: "a string" }, password: "x" });
expect(res.status).toBe(400);
});
A staging login that still uses findOne(req.body) will stay green in a happy-path test. The object-email case above is the one that must sit in CI. If you cannot post JSON in CI, run the same body with curl against staging you own. Expect 400. A 200 with a session is the miss. Do not point that curl at a host you do not operate.
Grep the hatches this page named:
rg -n "findOne\\(\\s*req\\.(body|query)|find\\(\\s*req\\.(body|query)|bindIpAll\\s*:\\s*true|0\\.0\\.0\\.0|role:\\s*[\\\"']root" \
--glob '!node_modules'
Questions we keep getting
Does Atlas mean I can skip these locks?
Hosted Mongo enables auth and TLS on the wire. You still bind the app to a string filter and a narrow user. findOne(req.body) is an application bug on Atlas too.
Is mongo-sanitize enough by itself?
No. It strips $ keys. It does not replace asString, and it does not enable authorization. Use it as a belt on unknown objects. Prefer typed fields.
Can I bind 0.0.0.0 if the firewall is tight?
Prefer a specific private address. A firewall you forget on the next image is how 27017 becomes public. Bind plus firewall plus auth. Not bind-all plus hope.



