Validating Encrypted Fields with $jsonSchema
Once a field is encrypted client-side it reaches MongoDB as opaque binData, and the question this page answers is exactly which $jsonSchema keywords still do useful work on that ciphertext — and which are silently pointless. It sits under GDPR Field-Level Encryption with Schema Validation within the broader Schema Validation for Compliance & Audit Readiness framework, narrowing from the full encryption-plus-validation workflow to the precise validator you write for an encrypted collection. The reader outcome is a runnable validator that requires each protected field, pins it to binData, and actively rejects the one failure mode that matters most in a privacy context: a personal field accidentally written in plaintext. The trap is that ciphertext looks like an ordinary binary value to the server, so the intuitive value-level guards you would reach for — pattern on an email, enum on a country, minLength on an identifier — are not merely ineffective, they cannot be evaluated at all, because the server never holds the cleartext to test them against.
Operational Mechanics and Write-Path Impact
A collection validator is evaluated by the query engine on the write path — insert, update, replace, findAndModify — at the storage layer, with no access to encryption keys. For an encrypted field the engine sees the stored BSON: a binary value of subtype 6, the reserved encrypted subtype. That constrains the usable keyword surface to type and presence assertions. Exactly three constructs carry meaning on a ciphertext field:
bsonType: "binData"asserts the field is stored as binary. Since an encrypted value is alwaysbinData, this is the affirmative “the field is encrypted-shaped” check. It matches any binary subtype —$jsonSchemacannot pin subtype6specifically — but crucially it does not match a nativestring, so it fails the moment a field arrives as plaintext.requiredasserts the field is present at all. Without it, a document that simply omits the sensitive field passes, which for a mandatory PII field is itself a compliance gap.notwithbsonTypeexpresses the same guard from the negative direction: reject the document if the field is astring(or any plaintext type). This is the most explicit way to write “this field must never be cleartext,” and it reads unambiguously in an audit.
Everything else is unusable. pattern, enum, minLength, maxLength, minimum, and maximum all inspect the decrypted value, which the server does not possess; applied to a binData field they either never match or are rejected at parse time. The table below is the complete decision surface for a validator over encrypted fields.
| Keyword | Usable on ciphertext? | Effect | Why |
|---|---|---|---|
bsonType: "binData" |
Yes | Confirms the field is stored encrypted-shaped | The server sees the binary type directly |
required |
Yes | Confirms the field is present | Presence is visible without decryption |
not + bsonType: "string" |
Yes | Rejects accidental plaintext | Native type is visible without decryption |
pattern |
No | Would test the cleartext format | Regex needs the decrypted string |
enum |
No | Would allowlist cleartext values | Comparison needs the decrypted value |
minLength / maxLength |
No | Would bound cleartext length | Ciphertext length is unrelated to plaintext length |
minimum / maximum |
No | Would bound a numeric value | The number is inside the ciphertext |
The write-path impact is negligible: a bsonType and required check adds trivial CPU per write compared with a deeply nested value-level schema, so a strict validator on an encrypted collection is cheap to run under validationAction: "error".
Exact Diagnostic Fingerprints and Fast Resolution
The signature that matters is a code 121 naming an encrypted field on a bsonType failure. Because a correctly configured client always writes the field as binData, a bsonType violation on that field means the value arrived as a native type — a plaintext personal value that the validator just stopped from being persisted in the clear.
from pymongo.errors import WriteError
try:
customers.insert_one(doc)
except WriteError as exc:
if exc.code == 121:
rules = exc.details["errInfo"]["details"]["schemaRulesNotSatisfied"]
# A bsonType failure on 'email' or 'ssn' == the field was NOT encrypted.
print("plaintext-leak guard fired:", rules)
# Example rules payload:
# [{'operatorName': 'properties', 'propertiesNotSatisfied': [
# {'propertyName': 'email', 'details': [
# {'operatorName': 'bsonType', 'specifiedAs': {'bsonType': 'binData'},
# 'reason': 'type did not match',
# 'consideredValue': 'ana@example.com',
# 'consideredType': 'string'}]}]}]
The consideredType: "string" together with a human-readable consideredValue in the failure detail is the unambiguous fingerprint of an un-encrypted write: the personal value is right there in the error as cleartext, which is itself the proof it never got encrypted. Route this signal to an alert, because it means some write path is connecting without automatic encryption. The signatures to match:
| Signature | Root cause | Resolution |
|---|---|---|
code 121, bsonType failure on a PII field, consideredType: "string" |
A client wrote the field without automatic encryption | Ensure that client is built with AutoEncryptionOpts; add the field to the encryption schema map |
code 121, required failure on a PII field |
The mandatory personal field was omitted entirely | Fix the producer to always supply the field |
OperationFailure FailedToParse on collMod |
The validator used pattern/enum/minLength on a binData field |
Remove value-level keywords; keep only bsonType, required, not |
Step-by-Step Playbook
The sequence attaches the validator, then proves it rejects plaintext. Diverting documents that cannot be re-encrypted in place is a job for fallback routing for invalid documents rather than a forced patch.
1. Attach the validator. It requires the encrypted fields, pins each to binData, and — to make the plaintext-rejection intent explicit and auditable — adds a not guard that forbids the string type. The not is logically redundant with bsonType: "binData" but documents the security goal in the schema itself:
db.runCommand({
collMod: "customers",
validator: { $jsonSchema: {
bsonType: "object",
required: ["_id", "email", "ssn"],
properties: {
_id: { bsonType: "objectId" },
email: { bsonType: "binData", not: { bsonType: "string" } },
ssn: { bsonType: "binData", not: { bsonType: "string" } }
}
}},
validationLevel: "strict",
validationAction: "error"
})
// Expected output: { ok: 1 }
2. Prove a plaintext write is rejected. Connect without automatic encryption, attempt to insert a personal value as a native string, and assert the validator rejects it with code 121. This is the test that certifies the plaintext guard is live:
from pymongo import MongoClient
from pymongo.errors import WriteError
# A plain client — no AutoEncryptionOpts — so fields are sent as-is.
plain = MongoClient("mongodb://localhost:27017")
customers = plain.app.customers
def assert_plaintext_rejected() -> None:
try:
customers.insert_one({"email": "leak@example.com", "ssn": "123-45-6789"})
except WriteError as exc:
assert exc.code == 121, f"unexpected code {exc.code}"
detail = exc.details["errInfo"]["details"]["schemaRulesNotSatisfied"]
print("PASS: plaintext PII rejected 121:", detail[0]["operatorName"])
return
raise AssertionError("FAIL: plaintext PII was accepted — the guard is not enforcing")
if __name__ == "__main__":
assert_plaintext_rejected()
# Expected: PASS: plaintext PII rejected 121: properties
3. Confirm the encrypting client still succeeds. A write through a client built with AutoEncryptionOpts turns the same fields into binData and passes the validator, proving the rule blocks only plaintext, not legitimate encrypted writes.
Failure Modes & Rollback
Each stage has a distinct failure and a bounded recovery:
- The
collModis rejected at parse time. You included a value-level keyword (pattern,enum,minLength) on abinDatafield. MongoDB’s Draft 4 engine parses the schema but the keyword is meaningless on ciphertext and signals a design mistake; strip it and re-apply. Time-to-recover: onecollMod. - The plaintext test unexpectedly passes the insert (step 2). The validator is not actually enforcing — most often the collection is on
validationAction: "warn"or a stale validator without thebinDataassertion is live. Read the ground truth withdb.getCollectionInfos({ name: "customers" })[0].optionsand re-apply the correct rule. - Legitimate encrypted writes start failing with 121 (step 3). The encryption schema and the validator disagree about which fields are encrypted — a field is in the validator as
binDatabut not in the encryption schema map, so the client sends it plaintext. Reconcile the two against the same field inventory.
Rollback is a single metadata-only command. A soft rollback keeps the validator attached for telemetry but stops rejecting; a hard rollback removes it. Neither touches stored ciphertext or keys, so confidentiality is never affected by reverting the structural rule:
// Soft rollback — stop blocking writes, keep the schema for logging.
db.runCommand({ collMod: "customers", validationLevel: "moderate", validationAction: "warn" })
// Hard rollback — detach the validator entirely.
db.runCommand({ collMod: "customers", validator: {}, validationLevel: "off" })
Both take effect on the primary immediately and replicate through the oplog, so time-to-recover is the replication lag of the slowest secondary — typically seconds.
Frequently Asked Questions
Why can't I use pattern or minLength on an encrypted field?
Both inspect the decrypted value, and the server never decrypts. It sees only the ciphertext stored as binData, whose length and byte content are unrelated to the plaintext. A pattern regex would run against binary bytes and a minLength would bound the ciphertext, neither of which tells you anything about the real value. Run those checks in the application before the field is encrypted.
Is not: { bsonType: "string" } necessary if I already assert bsonType: "binData"?
Logically it is redundant — a value that is binData is already not a string, so the bsonType assertion alone rejects plaintext. The not guard is worth adding because it states the security intent explicitly in the schema, which makes the rule self-documenting for an auditor reviewing why the collection cannot hold cleartext personal data.
Can the validator confirm a field was encrypted with a specific algorithm or key?
No. bsonType: "binData" matches any binary value and $jsonSchema cannot inspect the binary subtype, the algorithm, or the key id, all of which live inside the encrypted payload the server cannot read. The validator proves a field is binary-shaped and not plaintext; proving it was encrypted correctly is the client's responsibility, enforced by the driver's encryption schema.
Related
- GDPR Field-Level Encryption with Schema Validation — the parent workflow pairing client-side encryption with a structural validator end to end.
- Schema Validation for Compliance & Audit Readiness — the section framework these validation controls produce evidence for.
- Security Boundaries in Schema Design — the broader case for structural constraints as a hardening layer.
- Fallback routing for invalid documents — where a rejected plaintext write goes so a 121 never means lost data.