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.
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 toproperties, 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$setto a grandfathered document skips validation, so a closed allowlist over a dirty collection is not yet a boundary. Reachstrictonly after the step 3 count is zero. patternPropertiesregex too broad. A pattern meant to allowlabel_en,label_frthat 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.
Related
- Security Boundaries in Schema Design — the parent guide to using validators as a persistence-layer security control.
- MongoDB JSON Schema Validation Architecture — the overarching write-path enforcement architecture this allowlist plugs into.
- Allowlisting Field Values with $jsonSchema enum — closing the values a field may hold, the natural complement to closing the set of fields.
- Limiting Document Size and Nesting Depth in Validators — bounding resource-exhaustion vectors once the field set is locked.
- Understanding MongoDB $jsonSchema Syntax — the keyword semantics behind allowlists and nested
properties.
For the authoritative keyword surface and collMod argument list, consult the official MongoDB schema-validation documentation.