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 always binData, this is the affirmative “the field is encrypted-shaped” check. It matches any binary subtype — $jsonSchema cannot pin subtype 6 specifically — but crucially it does not match a native string, so it fails the moment a field arrives as plaintext.
  • required asserts 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.
  • not with bsonType expresses the same guard from the negative direction: reject the document if the field is a string (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.

How the validator routes an encrypted field write by its stored type A write to an encrypted field such as email or ssn reaches the validator, which asks whether the stored value is binData. If it is binData the write is accepted and the ciphertext is stored. If it is a string or any other native type the write is rejected with code 121, catching a plaintext personal-data leak before it is persisted. Encrypted field write email · ssn value is binData? Accepted ciphertext stored Rejected · code 121 plaintext leak caught binData string / plaintext The only meaningful test on ciphertext is its stored type — a native type means an un-encrypted personal value

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 collMod is rejected at parse time. You included a value-level keyword (pattern, enum, minLength) on a binData field. 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: one collMod.
  • 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 the binData assertion is live. Read the ground truth with db.getCollectionInfos({ name: "customers" })[0].options and 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 binData but 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.