Preventing Field Injection with additionalProperties: false

An open $jsonSchema — one that names the fields you expect but says nothing about the fields you do not — silently accepts every extra key a client chooses to send, and that gap is the exact operational problem this page closes, sitting under security boundaries in schema design within the broader MongoDB JSON Schema Validation Architecture. The reader outcome is a closed contract in which additionalProperties: false plus a complete properties enumeration rejects any unexpected field at the write path, at every nesting level, so a buggy client or an attacker cannot smuggle a role, an is_superuser flag, or an unindexed blob into a document your application code never intended to store.

The vector is mass assignment. When a handler spreads a request body straight into an update or insert, any field the caller adds rides along — and if a downstream reader later trusts a field like account_tier that was never supposed to be settable from the outside, an ordinary write becomes a privilege escalation. An allowlist schema is the persistence-layer answer: the database itself refuses the extra key.

Operational Mechanics and Write-Path Impact

additionalProperties governs what happens to a field that is present in the document but absent from the properties map of the same object. Its default is true: unknown fields are permitted, which is why a schema that merely lists properties without the keyword is not a closed contract at all. Setting it to false flips the object into an allowlist — every key must be named in properties (or matched by patternProperties) or the whole write is rejected with WriteError code 121. The keyword is scoped to a single object level; it says nothing about the objects nested inside it, so a top-level allowlist over an open sub-document still lets arbitrary keys through the nested object.

The interaction with patternProperties is the subtlety that trips people. When both are present, a field first gets a chance to match a patternProperties regex; only fields that match no pattern and are not in properties are then judged by additionalProperties. So additionalProperties: false combined with patternProperties does not mean “reject everything unexpected” — it means “reject everything that neither an explicit property name nor a pattern accepts.” That is how you allowlist a family of dynamic keys (say, per-locale labels) while still closing the object to genuinely foreign fields.

Object configuration Unknown field is_superuser Field matching a declared pattern Effect on write path
properties only, no additionalProperties Accepted (default true) Accepted Open — injection succeeds
additionalProperties: false, properties complete Rejected, code 121 n/a Closed allowlist
additionalProperties: false + patternProperties Rejected unless it matches a pattern Accepted if it matches Closed to foreign keys, open to the pattern family
additionalProperties: false at top level, open sub-doc Rejected at top level n/a Sub-document still injectable — must close each level

Because the check runs inside the server on insert, update, replace, and findAndModify, it holds regardless of which driver or ODM issued the write. The one caveat is that a partial $set update under validationLevel: "moderate" skips validation entirely for a document that is already non-compliant, so a closed allowlist must be paired with strict enforcement to be a genuine boundary — the levels are compared under strict vs moderate validation levels.

Exact Diagnostic Fingerprints and Fast Resolution

When an injected field is rejected, MongoDB 5.0+ returns a details object whose schemaRulesNotSatisfied array names the keyword additionalProperties and lists the offending keys under an additionalProperties element. That is the single most useful security signal in the whole boundary: it tells you the exact field an untrusted producer tried to add.

// Reproduce the fingerprint: an injected privilege flag is rejected.
db.user_accounts.insertOne({
  email: "casey@example.com",
  role: "member",
  is_superuser: true          // not declared in properties
})
/* Expected: WriteError 121 with errInfo.details.schemaRulesNotSatisfied naming:
   { operatorName: "additionalProperties",
     specifiedAs: { additionalProperties: false },
     additionalProperties: [ "is_superuser" ] }  */

The keyword named in the failure tells you the disposition. Match on these signatures to route the incident:

Signature Where it appears Root cause Resolution
operatorName: "additionalProperties" naming a privilege-like key (is_superuser, role, account_tier) errInfo.details.schemaRulesNotSatisfied A producer attempted mass assignment of an unauthorized field Treat as a potential injection; forward the field name to your SIEM, do not widen the schema to accept it
additionalProperties violation naming a nested key, top level clean Same array, deeper propertyName path A sub-document was left open (additionalProperties not set at that level) Add additionalProperties: false to the nested object
code: 121 on a document with only expected fields Driver / WriteError A legitimate new field was deployed before the schema was widened Version the schema and the producer together; ship the schema change first
Injected field written successfully No error Validator absent, moderate level over a legacy doc, or a patternProperties regex is broader than intended Re-count compliance with $nor; tighten the pattern

To audit a live collection for keys nobody declared, negate the registered schema with $nor$jsonSchema is a valid query operator, so this counts violations with no validator change and no writes:

db.user_accounts.countDocuments({ $nor: [ { $jsonSchema:
  db.getCollectionInfos({ name: "user_accounts" })[0].options.validator.$jsonSchema } ] })
// Expected output: 0 on a clean collection; any positive number is a backlog to triage.

Step-by-Step Playbook

The safe path builds a fully closed contract, proves the collection already conforms, then enforces — closing every nesting level, not just the root. Documents that cannot be normalized should be diverted through fallback routing for invalid documents rather than force-patched.

How additionalProperties false closes an object at every level An incoming write carries declared fields plus an injected key. The top-level allowlist checks each field against the properties map: declared fields pass, the injected is_superuser flag fails additionalProperties and the write is rejected with code 121. A second gate shows that a nested object must carry its own additionalProperties false, otherwise a foreign key inside the sub-document slips through the open nested object into storage. Client write declared fields + is_superuser Top-level allowlist additionalProperties : false foreign key Reject write code 121 · injection blocked declared Nested object must set its own additionalProperties:false Storage engine closed at every level

1. Enumerate every legitimate field and close the root. List each expected key in properties with a bsonType lock, then set additionalProperties: false. Anything not named is now rejected:

db.runCommand({
  collMod: "user_accounts",
  validator: { $jsonSchema: {
    bsonType: "object",
    additionalProperties: false,
    required: ["_id", "email", "role", "profile"],
    properties: {
      _id:   { bsonType: "objectId" },
      email: { bsonType: "string", pattern: "^[^@\\s]+@[^@\\s]+$" },
      role:  { bsonType: "string", enum: ["member", "editor", "admin"] },
      profile: { bsonType: "object" }   // still OPEN — closed in step 2
    }
  }},
  validationLevel: "strict",
  validationAction: "warn"
})
// Expected output: { ok: 1 }

2. Close every nested object. The profile sub-document above is still open, so an attacker could inject profile.is_superuser. Recurse the allowlist into each nested object:

db.runCommand({
  collMod: "user_accounts",
  validator: { $jsonSchema: {
    bsonType: "object",
    additionalProperties: false,
    required: ["_id", "email", "role", "profile"],
    properties: {
      _id:   { bsonType: "objectId" },
      email: { bsonType: "string", pattern: "^[^@\\s]+@[^@\\s]+$" },
      role:  { bsonType: "string", enum: ["member", "editor", "admin"] },
      profile: {
        bsonType: "object",
        additionalProperties: false,          // closes the sub-document
        properties: {
          display_name: { bsonType: "string", maxLength: 80 },
          locale:       { bsonType: "string" }
        }
      }
    }
  }},
  validationLevel: "strict",
  validationAction: "warn"
})

3. Prove the collection conforms. Run the $nor audit from the previous section. It must return 0 before you enforce — this is the go/no-go gate:

db.user_accounts.countDocuments({ $nor: [ { $jsonSchema: /* the schema above */ {} } ] })
// Expected output: 0

4. Promote to enforcement. Flip validationAction to error. The change is atomic and metadata-only — no collection rewrite:

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

Failure Modes & Rollback

Each stage fails in a distinct, bounded way:

  • Legitimate field rejected after step 4. A producer shipped a new field before the schema learned about it, so every write from that producer now fails with 121. The fix is ordering, not loosening: add the field to properties, deploy the schema, then deploy the producer. Track the two as one change through schema versioning strategies for NoSQL.
  • Injection still succeeds under moderate (steps 1–2). A partial $set to a grandfathered document skips validation, so a closed allowlist over a dirty collection is not yet a boundary. Reach strict only after the step 3 count is zero.
  • patternProperties regex too broad. A pattern meant to allow label_en, label_fr that is written as .* re-opens the object. Anchor patterns tightly and re-run the audit; treat the pattern surface itself as attack surface, since an unbounded regex is also a write-path denial-of-service risk.

Rollback is a single metadata-only collMod. Dropping to warn reopens the write path while keeping the schema attached for telemetry; the hard revert detaches it entirely:

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

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

Both take effect on the primary immediately 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 every field-injection vector the allowlist was closing.

Frequently Asked Questions

Does additionalProperties: false at the top level also protect nested objects?

No. The keyword only governs the object it is written on. A top-level allowlist leaves any sub-document open unless that sub-document carries its own additionalProperties: false and its own properties enumeration. An attacker who cannot add a root-level field can still inject one into an open nested object, so apply the allowlist recursively at every level you accept objects.

How do additionalProperties: false and patternProperties interact?

A field is checked against properties and patternProperties first; only a field that matches neither is judged by additionalProperties. So the two together mean "accept explicitly named fields and fields matching a declared pattern, reject everything else." This lets you allowlist a family of dynamic keys while still closing the object. Keep the pattern tightly anchored, because a loose regex quietly re-opens the object to injection.

Can a document be rejected for a field my application legitimately added?

Yes, and that is the allowlist working as designed. If a producer starts writing a new field before the schema names it, every such write fails with code 121. The correct fix is to add the field to properties and deploy the schema change ahead of the producer, never to relax the boundary to accept unknown fields.

For the authoritative keyword surface and collMod argument list, consult the official MongoDB schema-validation documentation.