How to create a read-only user in MongoDB

3 min readSeptember 9, 2026

Before connecting your MongoDB database to any external tool, create a dedicated read-only user. This limits the blast radius if credentials are ever leaked and prevents accidental writes from queries that go wrong.

MongoDB has no SQL GRANT statement. Access is granted by assigning built-in roles to a user with db.createUser(). For read-only access you want the read role, scoped to the one database you intend to expose.

Step 1: Create the user

Connect with an admin account and create the user in the admin database (ByeSQL authenticates against admin by default, so the authSource=admin connection option):

use admin

db.createUser({
  user: "byesql_reader",
  pwd: "choose-a-strong-password",
  roles: [
    { role: "read", db: "your_database_name" }
  ]
})

The read role allows find, aggregate, count, distinct, and listing collections and indexes, which is everything ByeSQL needs to introspect the schema (by sampling documents) and run aggregation-pipeline queries. It does not allow insert, update, delete, drop, createCollection, $out, or $merge.

Step 2: Connect ByeSQL to it

In the connection form use:

  • Host / Port: your mongod host and port (default 27017)
  • Database: your_database_name (the one you granted read on)
  • Username / Password: byesql_reader and its password

ByeSQL builds the connection with authSource=admin, matching where the user was created in Step 1. If your organisation creates application users inside the target database instead of admin, create the user with use your_database_name before db.createUser(...) and it will still work, because MongoDB resolves credentials against the database the user was defined in.

Grant on a specific database only

The { role: "read", db: "your_database_name" } entry scopes access to a single database. The user cannot read any other database on the same server. To confirm:

use admin
db.getUser("byesql_reader")

You should see a single roles entry: read on your_database_name.

Cluster-wide read access

If you want the user to read every database (for example, a multi-tenant deployment where each tenant is a database), use readAnyDatabase instead, but only if you actually need it:

db.createUser({
  user: "byesql_reader",
  pwd: "choose-a-strong-password",
  roles: [ { role: "readAnyDatabase", db: "admin" } ]
})

Prefer the single-database read role whenever possible. It is the least-privilege choice.

Limiting access to specific collections

The built-in read role covers every collection in the database. To hide certain collections, for example a payment_methods or user_credentials collection, create a custom role that grants find only on the collections you want to expose:

use admin

db.createRole({
  role: "byesqlReadSubset",
  privileges: [
    { resource: { db: "your_database_name", collection: "orders" },    actions: ["find"] },
    { resource: { db: "your_database_name", collection: "products" },   actions: ["find"] },
    { resource: { db: "your_database_name", collection: "customers" },  actions: ["find"] }
  ],
  roles: []
})

db.createUser({
  user: "byesql_reader",
  pwd: "choose-a-strong-password",
  roles: [ { role: "byesqlReadSubset", db: "admin" } ]
})

The user will be able to query only those three collections. Note that schema introspection will also only see those collections.

Verify the setup

Connect as the read-only user and test:

// As byesql_reader, against your_database_name:
db.orders.find().limit(1)                       // should succeed
db.orders.insertOne({ status: "x" })            // should fail: not authorized to insert
db.orders.aggregate([{ $out: "copy" }])         // should fail: not authorized for $out

Revoking access

use admin
db.dropUser("byesql_reader")
// and, if you created one:
db.dropRole("byesqlReadSubset")

A note on defense in depth

A read-only MongoDB user is your database-level safeguard. ByeSQL adds a second layer through SAFE mode: write and server-side-JavaScript stages ($out, $merge, $function, $where, $accumulator, mapReduce) are blocked at the application level before any query reaches your database, and every generated pipeline is forced to end with a $limit.

Both controls together mean that even if one layer has a gap, the other catches it. Use both.