Allowlisting Field Values with $jsonSchema enum

A field like status, role, type, or currency almost always has a closed vocabulary — a fixed set of values the rest of your system knows how to handle — and the operational question this page answers is how to pin that vocabulary at the database with $jsonSchema enum, sitting under security boundaries in schema design within the broader MongoDB JSON Schema Validation Architecture. The reader outcome is a validator that accepts only the values you have explicitly registered, rejecting an unknown role: "root" or a stray status: "REFUNDED " at the write path with WriteError code 121, so no unregistered category ever reaches a reader that would mishandle it or a privilege check that would trust it.

The design principle is that an allowlist beats a denylist. A denylist enumerates the bad values and fails open on everything it forgot; an enum enumerates the good values and fails closed on everything else — including the value an attacker invents tomorrow. For a privilege field, that difference is the whole security story.

Operational Mechanics and Write-Path Impact

enum constrains a field to an exact set of allowed values. On every insert, update, replace, and findAndModify, the server compares the field’s value against the array; a value not in the array fails the keyword and the write is rejected. Two properties of that comparison matter for a security boundary. First, enum is type-sensitive: the match is by BSON value, so the string "1" does not satisfy an enum of [1, 2, 3], and an integer 1 does not satisfy ["1", "2"]. Second, enum is case- and whitespace-sensitive: "Active", "active", and "active " are three different values, and only an exact member passes.

Because of type sensitivity, enum pairs naturally with bsonType. Declaring bsonType: "string" alongside the enum gives two independent guarantees — the value is a string and it is one of the allowed strings — and produces a cleaner failure signature, because a type-confusion attempt fails on bsonType while an unregistered category fails on enum. Without the bsonType lock, a client sending status: 1 fails only the enum check, which is harder to distinguish from a genuine bad category during triage.

Field enum allowlist Paired bsonType What it stops
status ["open", "settled", "void"] string Typos, stale states, injected sentinel values
role ["viewer", "editor", "owner"] string Privilege escalation to an unregistered role such as root
currency ["USD", "EUR", "GBP"] string Unsupported currencies that break settlement math
type ["card", "bank", "wallet"] string Unknown payment instruments a processor cannot route

The most security-relevant use is a privilege field. If role is pinned to an enum, a mass-assignment attempt that sets role: "superadmin" cannot persist, because "superadmin" is not a registered member — the escalation is stopped at the database rather than depending on every application code path to re-check the value. This complements closing the set of fields with an allowlist, covered under preventing field injection with additionalProperties: false; together they close both which keys and which values a document may carry.

One operational consequence follows directly from the closed set: widening an enum is a schema change, not a data change. Adding a new allowed value means editing and redeploying the validator, and until you do, every write carrying the new value fails. That is a feature — it forces a new category to be a reviewed, versioned event — but it means the enum must move in lockstep with producers through your schema versioning strategies for NoSQL.

Exact Diagnostic Fingerprints and Fast Resolution

An enum rejection on MongoDB 5.0+ names the keyword enum in schemaRulesNotSatisfied, reporting what the value was and what the allowed set is. That fingerprint distinguishes a benign typo from an attempted injection and from a legitimate new category the schema has not yet learned:

// Reproduce the fingerprint: an unregistered role is rejected.
db.accounts.insertOne({ account_id: "a-100", role: "superadmin" })
/* Expected: WriteError 121 with errInfo.details.schemaRulesNotSatisfied naming:
   { operatorName: "enum",
     specifiedAs: { enum: ["viewer", "editor", "owner"] },
     consideredValue: "superadmin" }  */

A short pymongo check surfaces the same structure programmatically, so a pipeline can route a rejection by the offending value rather than guessing:

from pymongo import MongoClient
from pymongo.errors import WriteError

client = MongoClient("mongodb://primary:27017")
accounts = client.platform_db.accounts

def create_account(doc: dict) -> str | None:
    """Insert an account, isolating an enum violation on the role field."""
    try:
        return str(accounts.insert_one(doc).inserted_id)
    except WriteError as exc:
        if exc.code == 121:
            rules = exc.details["errInfo"]["details"]["schemaRulesNotSatisfied"]
            for rule in rules:
                if rule.get("operatorName") == "enum":
                    bad = rule.get("consideredValue")
                    print(f"unregistered value {bad!r} for enum field; not persisting")
                    return None  # do NOT retry — the value is deterministically invalid
        raise

# create_account({"account_id": "a-1", "role": "superadmin"}) -> None (rejected)

Match on these signatures to route the incident:

Signature Root cause Resolution
operatorName: "enum" on a privilege field naming a value like root/superadmin Escalation attempt via mass assignment Treat as a security event; forward consideredValue to your SIEM; never widen the enum to accept it
operatorName: "enum", value differs only in case or trailing space ("Active", "active ") Producer emits an inconsistently-cased or padded value Normalize at the producer (trim/lower) rather than adding the variant to the enum
operatorName: "bsonType" on an enum field Type confusion — a number sent where a string is expected Fix the producer’s type; the paired bsonType lock caught it before enum
operatorName: "enum" on a genuinely new, approved category The vocabulary legitimately grew Widen the enum as a versioned schema change, deploy it ahead of the producer

To find documents already holding an unregistered value, negate the schema with $nor, since $jsonSchema is also a query operator:

db.accounts.countDocuments({ $nor: [ { $jsonSchema: {
  bsonType: "object",
  properties: { role: { bsonType: "string", enum: ["viewer", "editor", "owner"] } }
} } ] })
// Expected output: 0 on a clean collection.

Step-by-Step Playbook

The safe path registers the vocabulary, proves the data conforms, enforces, then treats every future widening as a versioned change. Values that cannot be normalized should be diverted through fallback routing for invalid documents rather than silently dropped.

An enum allowlist accepts registered values and rejects everything else Three incoming values for a role field reach the enum gate holding the allowlist viewer, editor, owner. The value editor is a registered member and passes to the storage engine. The value superadmin is not a member and fails the enum keyword, rejected with code 121 as an escalation attempt. The value Owner with a capital O fails too, because enum is case-sensitive, and is routed for normalization rather than accepted. role: "editor" registered role: "superadmin" not a member role: "Owner" wrong case enum gate [viewer, editor, owner] member Storage engine persisted not a member Reject write code 121 · escalation or normalize casing

1. Register the vocabulary with a paired type lock. Declare each closed-vocabulary field with both bsonType and enum, so a type-confusion attempt and an unregistered value produce distinct fingerprints:

db.runCommand({
  collMod: "accounts",
  validator: { $jsonSchema: {
    bsonType: "object",
    required: ["account_id", "role", "status"],
    properties: {
      account_id: { bsonType: "string", minLength: 1 },
      role:   { bsonType: "string", enum: ["viewer", "editor", "owner"] },
      status: { bsonType: "string", enum: ["active", "suspended", "closed"] }
    }
  }},
  validationLevel: "strict",
  validationAction: "warn"
})
// Expected output: { ok: 1 }

2. Prove the existing data conforms. Run the $nor audit for each enum field. It must return 0 before enforcement:

db.accounts.countDocuments({ $nor: [ { $jsonSchema: {
  bsonType: "object",
  properties: {
    role:   { enum: ["viewer", "editor", "owner"] },
    status: { enum: ["active", "suspended", "closed"] }
  }
} } ] })
// Expected output: 0

3. Promote to enforcement. Flip validationAction to error; the change is atomic and metadata-only:

db.runCommand({ collMod: "accounts", validationAction: "error" })
// Expected output: { ok: 1 }

4. Widen the vocabulary as a versioned change. When a new value is genuinely needed, add it to the enum and redeploy the validator before the producer starts emitting it, so the boundary never rejects an approved write:

db.runCommand({ collMod: "accounts", validator: { $jsonSchema: {
  bsonType: "object",
  required: ["account_id", "role", "status"],
  properties: {
    account_id: { bsonType: "string", minLength: 1 },
    role:   { bsonType: "string", enum: ["viewer", "editor", "owner"] },
    status: { bsonType: "string", enum: ["active", "suspended", "closed", "dormant"] }
  }
}}})
// "dormant" is now a registered member.

Failure Modes & Rollback

Each stage has a distinct failure mode and a bounded recovery:

  • Approved value rejected after step 3. A producer emitted a legitimately new category before the enum was widened. This is an ordering failure: widen the enum and deploy the schema first, then the producer — never loosen the field to a free-form string to unblock a release, because that discards the boundary permanently.
  • Case/whitespace churn (step 2 count won’t reach zero). Values like "Active" and "active " register as distinct members and inflate the violation count. Normalize at the producer (trim and case-fold) rather than adding every casing variant to the enum, which would defeat the point of a canonical vocabulary.
  • Type-confusion mixed with category errors. Without the paired bsonType, a numeric status: 1 fails only enum and is hard to tell from a bad category. Keep the type lock so the two failures stay separable during triage.

Rollback is a single metadata-only collMod. The soft rollback reopens writes while keeping the enum attached for telemetry; the hard rollback detaches it:

// Soft rollback: stop rejecting, keep the vocabulary for logging.
db.runCommand({ collMod: "accounts", validationAction: "warn" })

// Hard rollback: detach the validator entirely (last resort).
db.runCommand({ collMod: "accounts", validator: {}, validationLevel: "off" })

Both take effect on the primary at once and propagate through the oplog, so time-to-recover is the replication lag of your slowest secondary — typically seconds. Prefer the soft rollback; a hard rollback reopens the field to any value, including the escalation values the enum was rejecting.

Frequently Asked Questions

Is $jsonSchema enum matching case-sensitive and type-sensitive?

Yes on both counts. The comparison is an exact BSON value match, so "Active", "active", and "active " are three different members, and the string "1" never satisfies an enum of integers. Register one canonical form per value and normalize at the producer; pair the enum with bsonType so a wrong-typed value fails on the type keyword rather than being confused with a bad category.

Why is an enum allowlist better than a denylist for a privilege field?

A denylist enumerates the forbidden values and fails open on anything it did not anticipate, so a value invented after the rule was written slips through. An enum enumerates the permitted values and fails closed on everything else, so an injected role: "superadmin" is rejected simply because it was never registered. For a field that gates privilege, failing closed is the only safe default.

How do I add a new allowed value without rejecting live writes?

Widening an enum is a versioned schema change. Add the value to the enum array and redeploy the validator with collMod before the producer starts emitting it. Because collMod is metadata-only and atomic, the new member is accepted the instant it lands; deploying it ahead of the producer guarantees no approved write is ever rejected during the rollout.

For the authoritative behavior of enum and the collMod argument surface, consult the official MongoDB schema-validation documentation.