Your MongoDB Read-Only User Can Still Drop the Collection
DBShifts Engineering
The team building the migration platform
While verifying the read-only setup commands we publish for seven database engines, one of them failed in a way we did not expect. Not the commands — those were fine. The guarantee was missing, and nothing on the client side said so.
This is documented MongoDB behaviour, not a vulnerability. We are writing it up because every check a careful person would run to confirm they are protected passes.
The setup everyone does
You want someone — an analyst, a BI tool, an AI agent — to read production without being able to change it. So you create a user with the read role:
use blogdemo
db.createUser({
user: "reporting_ro",
pwd: "a-strong-password",
roles: [ { role: "read", db: "blogdemo" } ]
})Then you do the responsible thing and verify it — connect as the new user and ask MongoDB who you are:
mongosh "mongodb://reporting_ro:a-strong-password@localhost:27017/blogdemo"
> db.runCommand({ connectionStatus: 1 }).authInfo
{
authenticatedUsers: [ { user: 'reporting_ro', db: 'blogdemo' } ],
authenticatedUserRoles: [ { role: 'read', db: 'blogdemo' } ]
}Authenticated. One role. read. A count query returns rows, so the connection works. Every signal says this is a read-only user.
It is not
> db.orders.countDocuments({})
1
> db.orders.insertOne({ _id: 2, customer: "evil", total: 0 })
{ acknowledged: true, insertedId: 2 } // succeeded
> db.orders.drop()
true // succeeded
> db.orders.countDocuments({})
0read role, MongoDB confirms that role on the live connection, and the same connection inserts a document and drops the collection.Why
The server was started without access control — no --auth, no security.authorization: enabled. When MongoDB runs that way it does not enforce roles at all. Users and roles can still be created, and they are still reported, but nothing is ever checked against them.
That last part is what makes this worth an article. The server does not ignore your credentials and fall back to anonymous — it accepts them, records the login, and answers connectionStatus with the exact role you granted. The authentication is real. The authorization is theatre.
The same commands, on a server with --auth
Identical user, identical role, identical connection string:
> db.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles
[ { role: 'read', db: 'blogdemo' } ] // identical to the broken server
> db.orders.insertOne({ _id: 2 })
MongoServerError[Unauthorized]: not authorized on blogdemo to execute command insert
> db.orders.drop()
MongoServerError[Unauthorized]: not authorized on blogdemo to execute command dropThe role report is identical between a server that protects you and one that does not. There is no field to check.
How to actually tell
Two checks. The first is obvious once you stop trusting the role report — attempt a write and require it to fail:
db.__ro_probe.insertOne({ t: new Date() })
// Expected: MongoServerError[Unauthorized]
// Anything else means you are not protected. Clean up if it succeeded.The second is the one we like, because the symptom is inverted from intuition:
db.adminCommand({ getCmdLineOpts: 1 })
// Access control ON: MongoServerError[Unauthorized] <- good
// Access control OFF: { parsed: { security: {} }, ... } <- badread role on one database has no business reading server configuration.The fix
- Start mongod with
--auth, or setsecurity.authorization: enabledinmongod.conf, and restart. - Create the root user before enabling it, or you will lock yourself out. The official images do this via
MONGO_INITDB_ROOT_USERNAME. - Re-run the write probe above and confirm it now fails. Do not skip this step: confirming the role is exactly the check that did not work.
Where this actually bites
Nobody runs production this way on purpose. The trouble is that an unauthenticated mongod is the default in a lot of places people do not think of as production — a Docker Compose file copied from a tutorial, a staging box, an internal analytics replica, a container someone started to try something and left running with a copy of real data in it.
Those are exactly the servers people point read-only tooling at, because they are the ones you are allowed to touch. It is also why, when we built per-engine read-only guides, we ran every command against a live server of that engine rather than writing down what the documentation implies. Six engines behaved as documented. This one did not, and no amount of reading would have shown it.
There is a broader version of this. A database role is a claim you can ask about but not easily test from the inside, and a claim is not a guarantee. If a guarantee matters, probe for the failure you are counting on.
Migrate with the platform behind these posts
All 49 engine pairs live-tested. Validation, rollback, and CDC built in.
Start Free Migration