references/security.md
Security
Layered: network, authentication, authorization, encryption, application, audit. Any one of them alone is insufficient, and the first two are where real breaches happen.
Network
Every public MongoDB incident is the same story: a database reachable from the internet with authentication disabled. Scanners find an open 27017 within minutes.
- Never expose the database publicly. Private subnet or VPC, allowlist the
application hosts only.
- Bind to private interfaces.
bindIpshould not be0.0.0.0on a host
with a public address.
- Do not publish the port in Docker.
ports: ["27017:27017"]in a compose
file on a public VM publishes the database to the world. Use expose, or bind to 127.0.0.1:27017:27017 when a local tool genuinely needs it.
- TLS in transit, including between application and database inside a
private network. Internal networks are not trusted boundaries.
The Docker point deserves emphasis because it is the most common way a carefully-configured cluster ends up exposed: the database was fine, the compose file published it.
Authentication and RBAC
- Authentication on, always, including local development. A dev habit of
running without auth is how a staging box ends up unauthenticated.
- One user per application, with only the roles it needs. An app that reads
and writes its own database needs readWrite on that database, not readWriteAnyDatabase and certainly not root.
- Separate credentials for migrations (needs DDL), for the application
(needs data access), and for analytics (needs read-only). Different blast radius, different credentials.
- No shared human accounts. Named users, or SSO through the platform.
- Rotate credentials on a schedule and on staff changes. This requires the
app to reload credentials without a rebuild, which is a design decision to make early.
Custom roles are worth the effort when the built-ins are too broad:
db.createRole({
role: "appService",
privileges: [{
resource: { db: "app", collection: "" },
actions: ["find", "insert", "update", "remove"] // no dropDatabase, no createIndex
}],
roles: []
})
Withholding dropDatabase and dropCollection from the application user turns a catastrophic bug into a failed operation.
Query injection
The MongoDB-specific vulnerability, and it is easy to miss because it does not look like SQL injection.
If a request body reaches a query unvalidated, an attacker sends an operator object instead of a value:
{ "email": "admin@example.com", "password": { "$gt": "" } }
password: {"$gt": ""} matches any password, and the login succeeds. Variants use $ne, $regex, or $where.
Defences, in order of importance:
- Validate into typed models before querying. With Pydantic or equivalent,
password: str rejects a dict outright. This alone closes the class of bug, and it is why the fix belongs at the API boundary rather than in the data layer.
- Never spread client input into a filter.
find({**request.json})is the
pattern to ban.
- Reject keys beginning with
$or containing.if you must accept
dynamic filters at all.
- Disable server-side JavaScript (
$where,$function,
mapReduce) unless genuinely required. It is the difference between reading data and executing code.
Sort and projection parameters need the same treatment. A client-controlled projection can be used to extract fields the endpoint never intended to return.
Encryption
At rest is table stakes: encrypted volumes, or the storage engine's native encryption. It protects against a stolen disk or snapshot, and nothing else.
In use is where MongoDB has something distinctive. Client-Side Field Level Encryption and Queryable Encryption encrypt specific fields in the driver, so the server stores and returns ciphertext and never holds the keys.
Worth it for a defined set: national identifiers, bank details, health data, anything with a regulatory penalty attached. Queryable Encryption additionally allows equality and range queries against encrypted fields, which is what makes it usable rather than an archive format.
The cost is real: key management, a driver that supports it, larger documents, and a hard limit on what queries are possible. Encrypt the sensitive fields, not the whole document.
For a consultancy, the useful default is: know which fields in each app are regulated, and encrypt those specifically. "Encrypt everything" is a plan that does not survive its first aggregation.
Application-level rules
- Never build a query from an unvalidated dict. See injection above.
- Return 404, not 403, for another tenant's or another user's object.
A 403 confirms the id exists.
- Do not leak driver errors. A duplicate key error contains the index name
and the offending value. Log it with a request id, return something generic.
- Cap
limiton every list endpoint and paginate by cursor. An uncapped
limit is a denial-of-service endpoint.
maxTimeMSon user-facing operations, so one pathological query cannot
hold a connection indefinitely.
- Do not log full documents on paths that touch credentials, tokens, or
personal data. Redact at the formatter rather than at each call site.
Auditing
Enterprise and Atlas support an audit log of authentication and data-definition events. Where it is available, enable it for authentication attempts, role changes, and schema changes at minimum.
Where it is not, keep an application-level audit collection for security-relevant actions: privilege changes, exports, deletions, admin impersonation. Append-only, with actor, timestamp, and target. This is what a customer asks for after an incident, and it cannot be reconstructed retroactively.
Backups
Backups are not the deliverable. A verified restore is.
- Automated, with a retention window matching the contract.
- Test restores on a schedule. A backup nobody has restored is a hypothesis.
- Know the RPO and RTO and write them down, since a customer will eventually
ask.
- Point-in-time recovery for anything transactional. Daily snapshots mean a
bad migration at 16:00 costs a day of orders.
- Backups contain everything the database contains: encrypt them, and control
access at least as tightly as the database itself.
- For shared-collection multi-tenancy, per-tenant restore is a script, not a
feature. If a contract promises it, build and test it before signing.
Deployment checklist
Per app, before it carries real data:
- Not reachable from the public internet; port not published in compose
- Authentication enabled, TLS on
- Application user has least privilege; no
root, nodropDatabase - Separate credentials for migrations and analytics
- Credentials from a secret manager, never committed
- Server-side JavaScript disabled
- All input validated into typed models before reaching the driver
-
$jsonSchemavalidation on every collection - Regulated fields identified and field-encrypted
- Backups automated and a restore rehearsed
- Slow query log monitored
- Tenant isolation test in the suite