Skip to main content
MongoDB guardrails work differently from SQL ones, and what a pattern sees depends on how the connection was made. On a native connection there is no query text on the wire, so a pattern written for SQL never matches. On Web App / one-off access, the reverse is true — your literal shell text is exactly what the guardrail sees. This page explains both, then gives tested recipes for the operations people usually want to block, written for each.
For rule creation, actions, and pattern syntax basics, see Guardrails Configuration.

What your patterns match against

mongosh and Compass are JavaScript interpreters. They evaluate your expression locally and send only the resulting command document — the shell text is never transmitted.
The guardrail sees this instead:
So you write patterns against MongoDB’s command grammar — the same names in the MongoDB command reference. Three details make the recipes below work: The command name is always the first field. MongoDB requires it. That makes \{"drop": a reliable signature for this command is a drop, rather than the word drop appears somewhere in the payload. $db names the target database, on every command. Operators keep their names as JSON keys, at any depth. A pattern for "\$out" finds it inside a nested $lookup sub-pipeline just as easily as at the top level.
The payload also contains the driver’s session envelope — lsid, $clusterTime and signature, each carrying base64 blobs on every command. Do not assign generic secret-detection or high-entropy patterns to MongoDB connections as input rules: they will match constantly.
$ means end-of-string in a regex, and every MongoDB operator starts with $. Escape it as \$ or the pattern silently matches nothing.

Webapp access: raw text, not command documents

Everything above assumes a native connection — a real mongosh or Compass process, proxied by Hoop, evaluating your JavaScript locally and sending only the resulting command document. Web App and one-off access work differently. When a resource role permits Web App / one-off access — the query box in the Hoop Web Console, or hoop exec <role> -i "..." from the CLI — there is no local shell to evaluate your input first. What you type is what the guardrail sees, verbatim:
The guardrail matches against that literal text, not {"drop":"users","$db":"..."}. Recipes written for the command grammar (\{"drop":) never fire here — that exact string never appears, because the payload is shaped like JavaScript, not JSON.
A resource role that allows both native and Web App access needs two rules per operation — one written against the command document, one against the raw text. Assigning only one leaves the other path open. If a role doesn’t need native access, turning it off under that resource role’s Access Modes removes the wire-protocol path entirely, so only the raw-text rule is needed.
Three things follow from the text being literal and unevaluated: Quoting is optional and inconsistent. mongosh accepts unquoted object keys ({drop: "users"}), single quotes, double quotes, and whitespace anywhere. A pattern anchored to "drop": misses {drop:"users"} and { drop : "users" } alike — anchor on the method call instead (\.drop\s*\(), and treat a quote around a key as optional (["']?) rather than assumed. There is no reliable command-position anchor. The wire JSON always puts the command name first, which is what makes \{"drop": safe — an ordinary document can never open with that. Raw text has no such guarantee: $where can appear inside a live query, a comment, or a string a user is inserting, and a pattern can’t tell those apart. Anchor as tightly as the syntax allows (a leading . for a method call, db. for a namespace), and accept that some raw-text recipes are blunter than their command-document equivalents. Variable indirection defeats literal matching. A native connection always sends the final evaluated command, however it was built locally. Web App / one-off input is matched as typed, before anything would be evaluated — var f = {}; db.users.deleteMany(f) never contains the text deleteMany({}), so it slips past a pattern written for the literal call. There is no fix at the pattern layer; where this risk matters, pair the rule with Require Approval or restrict the resource role to native access only. Database context comes from a separate statement. There is no $db field to anchor on. use reporting or db.getSiblingDB("reporting") sets the database for whatever follows, but arrives as its own line — a rule scoped to one database needs to match that statement directly, not assume the name travels with every command.

Which rule type to use

Guardrail rules come in two types: a Pattern (pattern_match, a regex) and a word list (deny_words_list, a list of whole words). Every recipe on this page is a Pattern, and that is not incidental. MongoDB signatures identify a command by its JSON punctuation — \{"drop":, "q":\{\},"limit":0 — and a word list matches whole words, so it cannot express a signature that begins or ends on a brace, quote or colon. Use a word list when the thing you want to block genuinely is a whole word: A word list is the blunter of the two. salaries as a word list also fires when a document merely mentions the word, so prefer a Pattern whenever you need the match tied to a specific place in the command. The same logic carries over to raw text: punctuation is still what ties a match to a method call (\.drop\() or an operator (\$where\b) rather than incidental text, so the recipes below stay Pattern rules in both forms.

Recipes

Each recipe gives the Block rule for the same operation in both forms — switch the tab to match how the resource role is accessed: Native for the command-document Pattern (validated against real payloads), Raw Text for the Web App / one-off Pattern written against mongosh syntax. A role that allows both access modes needs both rules assigned. Shell syntax has more valid spellings than the wire protocol has encodings — run every raw-text rule through Test before you deploy below before assigning it widely.

block-collection-drop

Blocks dropping a collection.
The leading \{" ties the match to the command position, so an ordinary document containing the word drop does not trip it. dropDatabase and dropIndexes are separate commands with their own names — use the next recipe to cover all of them.

block-destructive-ddl

Blocks every irreversible namespace or index operation.

block-writes

A read-only connection needs two rules. This one covers write commands, and the other covers writes hidden inside an aggregation pipeline:
The second rule is not redundant. A pipeline write looks like this:
The command name is aggregate — a read command — so rule 1 does not match it, but $out writes a collection. A read-only policy with only the first rule has this hole. Assign both to the same resource role.

block-delete-all

Blocks deleting every document in a collection, while allowing scoped deletes.
MongoDB puts the delete scope on the wire, which makes this precise rather than approximate: limit: 0 means all matches, limit: 1 means one, and "q":{} is an empty filter. The pattern has two alternatives because deleteMany({}) has two wire encodings, depending on your driver version. Both empty the collection, so both must be blocked: The second alternative requires multi to follow filter immediately, which is what keeps it from firing on a bulkWrite update — that op carries updateMods between the two fields.
This matches an empty filter, not a filter that happens to match everything. db.users.deleteMany({_id:{$exists:true}}) empties the collection just as thoroughly and is not blocked. No pattern can close that gap — deciding whether a predicate selects every document means evaluating it against the data.
Where that gap matters, put the resource role behind Action Access Requests and add a second rule on \{"delete": with the Require Approval action. Every delete then waits for an approver from the groups you set with --reviewers, and the approver sees the command — including the filter this pattern cannot judge — before it runs.
That pattern matches deleteMany with any predicate, deleteOne, and the 8.0 bulkWrite delete op. Two things to weigh before assigning it:
  • db.users.deleteOne({_id:1}) waits for approval too. On an interactive connection that is real friction, so scope it to the resource roles that need it rather than all of them — see Read-Only with Approval for that shape, or Sensitive Operations - Dual Approval to require two groups.
  • It does not cover db.users.findOneAndDelete({_id:1}), which the server receives as findAndModify with remove: true. Add "remove":true as a second pattern if single-document deletes matter.
To catch any multi-document delete regardless of its filter, scope the pattern to the delete command: \{"delete":.*"limit":0. Do not use "limit":0 alone — in a find command limit: 0 means no limit, so it blocks the ordinary read db.users.find({}).limit(0).

block-update-all

Blocks updating every document in a collection.
multi: true marks a multi-document update and "q":{} an empty filter. This is also the technique for requiring two conditions together — a single pattern with .* spans between them.
This pattern depends on field order (q, then u, then multi), which is driver behaviour rather than a protocol guarantee. Prefer order-independent patterns where you can, and revisit this one after a major driver upgrade.

block-server-side-js

Blocks server-side JavaScript execution.
A $where clause is arbitrary JavaScript evaluated by the server:
$code is how any JavaScript value renders, which is why the same rule also covers mapReduce’s map and reduce functions. That command sends:
To test it, pass the functions inline — a bare map or reduce identifier is not defined in a fresh shell and throws ReferenceError before anything reaches the wire:
out: { inline: 1 } returns results instead of writing a collection, so a failed block leaves nothing behind. Map-reduce has been deprecated since MongoDB 5.0; if your server rejects the command outright, that error is not a guardrail result.

block-restricted-collection

Blocks one collection by name, without matching documents that merely mention it.
A query on a permitted collection can still reach a restricted one through a join. Add a second rule for "from":"salaries" to cover $lookup and $graphLookup.

Test before you deploy

Never test a blocking rule with a command that would be destructive if the block fails. A typo in the pattern, or a guardrail assigned to the wrong resource role, means the command runs. The commands below are safe. They target collections that do not exist (gr_test_zz), so even if a rule fails to match, MongoDB performs a no-op instead of destroying data. The “must pass” commands are filtered reads against the same non-existent collection, which return an empty result.
On MongoDB 8.0, verify the second wire encoding too. This is the same delete-everything operation a newer driver sends, and a pattern covering only the pre-8.0 form lets it through:
On a server older than 8.0 the command does not exist, so a failed block returns no such command: bulkWrite and changes nothing.
Do not test a dropDatabase rule from a connection pointed at a real database. Switch to a scratch database first — use gr_scratch_zz — so a failed block destroys nothing.
Two extra checks worth running:
  1. db.users.countDocuments({}) must not trigger a rule targeting count. countDocuments sends an aggregate command; only estimatedDocumentCount sends count.
  2. An explained command must trigger the same rule as the command itself: db.gr_test_zz.find({$where:"1"}).explain() blocks, because explain is unwrapped to the command it wraps.
Test in mongosh before Compass. Compass issues its own listCollections, $collStats and schema-analysis aggregate calls alongside your actions, so a broad rule can block Compass’s own metadata queries and make the session look broken rather than guarded.
Raw-text rules need their own pass through the same canary commands. Run them through the Web App query box, or hoop exec <role> -i "db.gr_test_zz.drop()" from the CLI — a rule tuned against the command document does not automatically cover the literal text, and vice versa.

What patterns cannot catch

Which command produced a match. A pattern for an SSN fires identically on a query searching by an SSN and a write storing one:
Both are one JSON string to the matcher. Different risks, same match. Anything expressed as an allowlist. Rules only block, so “permit only the analytics database” is not expressible — enumerate what to deny, with patterns like "\$db":"production". The size of a bulk operation. A pattern cannot count documents. Commands that are not evaluated. A pattern targeting these never fires: listCollections, listIndexes, reIndex, compact, shardCollection, user management such as createUser, cursor iteration (getMore), and heartbeats (hello, ping). For what patterns cannot express, layer your defenses:
  • Require Approval on the broadest rules, so a human sees the operation regex cannot judge.
  • Database-level controls: give the resource role’s MongoDB user a read-only role, or scope it to specific collections. The guardrail blocks the text; the database enforces the permission.
  • Runbooks: parameterized operations for day-to-day work, instead of free-form shell access.

Guardrails Configuration

Rule creation, actions, pattern syntax, and troubleshooting

SQL Guardrail Recipes

Patterns for tautologies, subqueries, CTEs, and missing WHERE clauses

Guardrails Overview

What guardrails do and how they fit with other features

Runbooks

Parameterized operations instead of free-form shell access