Role-Based Access Control

Role-Based Access Control (RBAC) is how MongoDB decides what an authenticated connection is allowed to do once it’s proven who it is. Instead of granting permissions to individual users one at a time, you attach one or more roles to a user, and each role bundles a set of privileges — an action such as find or insert paired with the exact resource (a collection, a whole database, or the entire cluster) that action applies to. Get RBAC right and a leaked application credential can only touch the data it actually needs; skip it, or forget to turn authorization on at all, and every connection that reaches your mongod has full read and write access to everything on the server.

Overview / How it works

MongoDB security splits into two concerns that are easy to conflate. Authentication answers “who are you?” — usually a SCRAM username/password handshake, but also x.509 certificates or an external identity provider (LDAP, Kerberos, OIDC on Atlas). Authorization answers “what are you allowed to do?” — and that’s entirely RBAC’s job. Crucially, authorization is only enforced if you’ve turned it on: a mongod started without --auth (or security.authorization: enabled in the config file) lets anyone who can open a TCP connection to the port act as an unrestricted superuser. Roles and privileges you’ve carefully defined do nothing on a deployment where authorization was never enabled.

Every user is created against a specific authentication database and is assigned an array of roles, stored internally in admin.system.users. A role is a named bundle of privileges, and a privilege is a pair: a resource (which database and collection — or the whole cluster — it covers) and a set of actions (verbs like find, insert, update, remove, createIndex, dropCollection, shutdown). When a client sends a command, MongoDB resolves every role assigned to that user — recursively, since a role can inherit privileges from other roles — into one flattened set of privileges, then checks whether any of them authorizes the requested action against the requested resource. If none does, the command fails with an Unauthorized error before it ever touches the storage engine, no matter how well-formed the query itself was.

MongoDB ships roughly a dozen built-in roles covering the common cases — read-only, read-write, database administration, user administration, cluster administration, backup/restore — each scoped to a single database unless you use an “AnyDatabase” variant (readAnyDatabase, root, and so on, which span the whole server). For anything more precise than “this account can read and write everything in this one database,” you define a custom role with db.createRole(), listing exactly the resource/action pairs it should grant. Custom roles are the backbone of least-privilege design: an order-processing service should hold a role that can find, insert, and update on the orders collection — nothing about dropDatabase, nothing about the system.users collection, nothing about databases it never touches.

Resource specifications have a shape that trips people up. { db: "shopdb", collection: "orders" } matches exactly that one collection. { db: "shopdb", collection: "" } matches every collection inside shopdb. { db: "", collection: "orders" } matches a collection named orders in every database on the server. { cluster: true } is reserved for cluster-wide actions that have no database context at all, such as shutdown or addShard. Be explicit when writing a custom role — an empty string is not the same as omitting the field.

Syntax

The core commands you’ll use from mongosh to manage users and roles:

db.createUser({
  user: "<username>",
  pwd: "<password>",
  roles: [
    { role: "<roleName>", db: "<database>" }
  ]
});

db.createRole({
  role: "<roleName>",
  privileges: [
    {
      resource: { db: "<database>", collection: "<collection>" },
      actions: ["find", "insert", "update"]
    }
  ],
  roles: []
});

db.grantRolesToUser("<username>", [{ role: "<roleName>", db: "<database>" }]);
db.revokeRolesFromUser("<username>", [{ role: "<roleName>", db: "<database>" }]);
Parameter Meaning
user / pwd Username and password for a SCRAM-authenticated account. In real deployments, use passwordPrompt() instead of a literal string so the password isn’t left in shell history.
roles Array of role assignments. A plain string names a built-in role on the current database; { role, db } references a role (built-in or custom) defined on a specific database.
role (in createRole) Name of the new custom role — unique within the database it’s created on.
privileges Array of { resource, actions } objects: the actual permissions the role grants.
resource Which database/collection (or { cluster: true }) the actions apply to.
actions Array of action-verb strings such as find, insert, update, remove, createIndex, dropCollection.
roles (in createRole) Other roles this new role inherits from; use an empty array for a standalone role.

Common built-in roles

Role Grants
read Read-only access to a single database’s non-system collections.
readWrite Read and write access to a single database’s non-system collections.
dbAdmin Schema-related admin tasks on one database: indexes, validate, collStatsnot data reads or writes.
userAdmin Create and manage users and roles on one database — not data access.
dbOwner Combines readWrite + dbAdmin + userAdmin on one database.
readAnyDatabase / readWriteAnyDatabase Same as read/readWrite but across every database on the server.
clusterAdmin Cluster-wide administration: replication, sharding, server status.
root Superuser — every privilege on every resource. Reserve for a small number of trusted human operators, never for application service accounts.

Examples

Example 1: Bootstrapping the first administrative user

A brand-new deployment with authorization enabled has no users at all, so MongoDB allows one connection from localhost without credentials just long enough to create the first user (the “localhost exception”). Use it to create an administrator, then switch to normal authenticated connections for everything else.

use admin
db.createUser({
  user: "appAdmin",
  pwd: passwordPrompt(),
  roles: [
    { role: "userAdminAnyDatabase", db: "admin" },
    { role: "readWriteAnyDatabase", db: "admin" }
  ]
});

Output:

{ ok: 1 }

passwordPrompt() interactively asks for the password instead of hardcoding it in the script, so it never lands in shell history or a saved .js file. userAdminAnyDatabase lets this account create and manage other users and roles cluster-wide — it does not, by itself, grant data access, which is why readWriteAnyDatabase is added alongside it.

Example 2: A scoped application user

Application service accounts should only ever see the one database they actually use.

use shopdb
db.createUser({
  user: "shopApp",
  pwd: passwordPrompt(),
  roles: [
    { role: "readWrite", db: "shopdb" }
  ]
});

Output:

{ ok: 1 }

This account can read and write any collection inside shopdb, but it has no visibility into any other database on the server, and no administrative privileges (it can’t manage users, drop the database, or touch admin.system.users).

Example 3: A least-privilege custom role

readWrite is often still broader than an application actually needs. Suppose a reporting worker should only ever insert and read from a single orders collection, and should never touch customers or products even though they live in the same database.

use shopdb
db.createRole({
  role: "orderWriter",
  privileges: [
    {
      resource: { db: "shopdb", collection: "orders" },
      actions: ["find", "insert", "update"]
    }
  ],
  roles: []
});

db.grantRolesToUser("shopApp", [
  { role: "orderWriter", db: "shopdb" }
]);

db.getUser("shopApp");

Output:

{
  _id: 'shopdb.shopApp',
  user: 'shopApp',
  db: 'shopdb',
  roles: [
    { role: 'readWrite', db: 'shopdb' },
    { role: 'orderWriter', db: 'shopdb' }
  ],
  mechanisms: [ 'SCRAM-SHA-1', 'SCRAM-SHA-256' ]
}

db.getUser() confirms shopApp now carries both roles. Note that orderWriter doesn’t grant remove — this account could never run deleteOne on orders even though it’s able to insert and update, because remove was never listed in the role’s actions.

How it works step by step

When a client authenticates, the server doesn’t just check a password — it establishes an identity tied to a specific (user, authenticationDatabase) pair. From that point, every command on that connection goes through the same check:

  1. MongoDB looks up the authenticated user’s roles array.
  2. Each role is expanded recursively — if a role itself lists other roles under its own roles field, those are pulled in too — until the whole inheritance graph collapses into one flat list of privileges.
  3. The requested command is mapped to one or more required actions (a find query needs the find action on the target collection’s resource; creating an index needs createIndex; running db.dropDatabase() needs dropDatabase at the database level).
  4. MongoDB checks whether any privilege in the flattened set matches both the required action and a resource that covers the target — an exact collection match, a database-wide wildcard, or a cluster-wide scope.
  5. If a match is found the command proceeds normally through the query planner and storage engine; if not, the server returns an Unauthorized error and the command never executes.

You can inspect your own connection’s resolved privileges directly, which is useful when debugging an unexpected Unauthorized error:

db.runCommand({ connectionStatus: 1, showPrivileges: true });

Output (trimmed):

{
  authInfo: {
    authenticatedUsers: [ { user: 'shopApp', db: 'shopdb' } ],
    authenticatedUserRoles: [
      { role: 'readWrite', db: 'shopdb' },
      { role: 'orderWriter', db: 'shopdb' }
    ],
    authenticatedUserPrivileges: [
      {
        resource: { db: 'shopdb', collection: '' },
        actions: [ 'find', 'insert', 'update', 'remove', 'createIndex' ]
      },
      {
        resource: { db: 'shopdb', collection: 'orders' },
        actions: [ 'find', 'insert', 'update' ]
      }
    ]
  },
  ok: 1
}

This is the flattened privilege set the authorization check above actually consults — it’s the ground truth when “why can’t this account do X” comes up.

Common Mistakes

Mistake 1: Defining roles but never enabling authorization

Users and roles are inert until the server is told to enforce them. Starting mongod without --auth means every connection — including ones with no credentials at all — gets full access, regardless of what roles exist.

# Wrong -- authorization is never enforced, RBAC has no effect
mongod --dbpath /data/db --bind_ip_all
# Correct -- enable access control
mongod --auth --dbpath /data/db --bind_ip_all

# or in mongod.conf:
# security:
#   authorization: enabled

Always verify with a fresh, unauthenticated connection that it’s actually rejected before trusting a deployment is locked down.

Mistake 2: Granting root or an “AnyDatabase” role to a service account

It’s tempting to hand an application’s database user broad roles to avoid permission errors during development, and then never revisit it.

// Wrong -- a web service account with unlimited server-wide access
db.createUser({
  user: "shopApp",
  pwd: passwordPrompt(),
  roles: ["root"]
});
// Correct -- scoped to exactly the database and actions the service needs
db.createUser({
  user: "shopApp",
  pwd: passwordPrompt(),
  roles: [ { role: "readWrite", db: "shopdb" } ]
});

If that credential ever leaks — in a log line, a committed .env file, a compromised container — the blast radius with root is “every database on the server,” versus “one database” with a scoped role.

Mistake 3: Assuming dbAdmin includes data access

dbAdmin sounds like it should let an account do anything administrative to a database, including querying it, but it’s scoped to schema-level operations only — indexes, validation, statistics — not documents.

// Wrong assumption -- dbAdmin alone cannot read documents
db.createUser({
  user: "opsUser",
  pwd: passwordPrompt(),
  roles: [ { role: "dbAdmin", db: "shopdb" } ]
});
// db.orders.find() as opsUser fails: "not authorized on shopdb to execute command"
// Correct -- add read explicitly if the account also needs to query data
db.grantRolesToUser("opsUser", [ { role: "read", db: "shopdb" } ]);

Treat every built-in role name as a hint, not a guarantee — check the privilege table (or db.getRole(name, { showPrivileges: true })) before assuming what it covers.

Best Practices

  • Follow least privilege: build custom roles scoped to the exact collections and actions an account needs rather than reaching for broad built-ins out of convenience.
  • Never give an application service account root, userAdmin, or any “AnyDatabase” role — reserve those for a small number of trusted human operators.
  • Enable authorization from day one, in development too, so permission bugs surface before production instead of after.
  • Use separate credentials per environment (dev, staging, prod) and per service, so one leaked credential doesn’t compromise everything.
  • Generate passwords with passwordPrompt() or pull them from a secrets manager — never hardcode them in scripts or commit them to source control.
  • Periodically audit roles with db.getRoles({ showPrivileges: true, showBuiltinRoles: false }) on each database and remove ones no longer in use.
  • Pair RBAC with TLS — authorization controls what an authenticated connection can do, it does not encrypt the traffic between the driver and the server.
  • Prefer a built-in role when it’s an exact match (e.g. read for a monitoring dashboard) instead of writing a custom role that just reimplements it.

Practice Exercises

  • Create a read-only user named reportViewer, authenticated against the admin database, that can only read (not write) the analytics database.
  • Create a custom role named auditReader that grants only the find action on a collection named auditLogs in every database on the server (hint: think about what an empty-string db in a resource spec matches), then grant it to a new user called auditor.
  • Given a user with roles [{ role: "readWrite", db: "shopdb" }, { role: "dbAdmin", db: "shopdb" }], use db.runCommand({ connectionStatus: 1, showPrivileges: true }) (while connected as that user) to determine whether they’re authorized to run db.orders.createIndex(...) and whether they’re authorized to run db.dropDatabase() — and explain why the two answers differ.

Summary

  • RBAC controls authorization — what an already-authenticated connection can do — and only takes effect once --auth/security.authorization: enabled is turned on.
  • A role is a bundle of privileges; a privilege pairs a resource (database/collection/cluster) with a set of allowed actions.
  • Built-in roles like read, readWrite, dbAdmin, and root cover common cases but are often broader than a single application needs.
  • db.createRole() defines custom, least-privilege roles scoped to exact collections and actions.
  • db.grantRolesToUser() / db.revokeRolesFromUser() manage which roles a user holds after creation.
  • Roles resolve recursively through inheritance into one flat privilege set that every command is checked against.
  • Application service accounts should never hold root or “AnyDatabase” roles — scope them tightly to limit the blast radius of a leaked credential.