Skip to content
Document DB 13 - MCP Server, REST API, Field Level Encryption, Transactional Outbox, & More!SHOW ME!!

Field-Level Encryption

Encrypt named properties at rest, on every provider, without changing how the application reads or writes documents. It is part of the core Shiny.DocumentDb package — no extra dependency, nothing to install.

var encryptor = new AesGcmDocumentEncryptor("k1", key); // key is 32 bytes
opts.UseEncryptor(encryptor);
opts.ConfigureDocument<Patient>(cfg => cfg.MapProperty(x => x.Ssn, p => p.Encrypt())); // opaque
opts.ConfigureDocument<Member>(cfg => cfg.MapProperty(x => x.Email, p => p.Encrypt(EncryptionMode.Deterministic))); // still queryable by equality
await store.Insert(new Patient { Id = "p1", Ssn = "123-45-6789" });
// stored: { "id": "p1", "ssn": "enc:1:k1:BASE64…" }
var patient = await store.Get<Patient>("p1");
// patient.Ssn == "123-45-6789"

The database, its backups, its replicas, its temporal history and the admin UI only ever hold ciphertext. Nothing in your code changes.

Encryption is installed as a JsonTypeInfo modifier that swaps in an encrypting JsonConverter for the mapped property. It is a serialization-level transform, which has three consequences worth knowing:

  • No provider support is needed. Every backend stores a JSON string. SQLite, PostgreSQL, Mongo, Cosmos, DynamoDB — all identical.
  • Every write path is covered by constructionInsert, Update, Upsert, BatchInsert, sessions, temporal history writes, and IDocumentBackup export. There is no path that serializes a document without going through the converter.
  • The raw JSON lanes are not decrypted. store.Collection(...) and the JSON collection surface return the envelope as-is, because there is no CLR type to attach a converter to. That is usually what you want from a raw lane; it is not a way to read the plaintext.

It is deliberately not an interceptor: interceptors are write-only, so an interceptor could encrypt but nothing would decrypt on the way back.

Randomized (default) Deterministic
Same value written twice different ciphertext same ciphertext
Equality filter (==, !=, WhereIn) throws works
Range, StartsWith, Contains, OrderBy, full text, vectors, GroupBy throws / meaningless throws / meaningless
JSON index over the property pointless works (equality lookups)
Leaks nothing which documents share a value, and how often each value occurs
Property types string, bool, int, long, double, decimal, Guid, DateTime, DateTimeOffset (+ nullable) string only

Deterministic mode is string-only because the query rewrite replaces the constant in x.Email == "a@b.com" with its ciphertext, and a ciphertext is a string — a decimal property cannot be compared against one without changing the type of the expression.

A deterministic property is queried exactly as before. The predicate’s constant is encrypted the same way before it reaches the store, across every query surface — typed LINQ, the string grammar, the interpolated form, OData, and the AI tools:

await store.Query<Member>().Where(x => x.Email == "a@x.com").ToList();
await store.Query<Member>().Where("Email == 'a@x.com'").Count();
await store.Query<Member>().First(x => x.Email == email);

Anything that cannot be answered against ciphertext throws NotSupportedException with an explanation rather than silently matching nothing:

await store.Query<Member>().Where(x => x.Email.StartsWith("a")).ToList();
// NotSupportedException: 'Member.Email' is encrypted at rest, so the store cannot evaluate 'StartsWith' —
// only equality against a deterministic property can be answered against ciphertext.

null comparisons keep working in both modes: a null property is stored as JSON null, not as an envelope, so x.Ssn == null and x.Ssn != null are ordinary predicates.

IDocumentEncryptor is the seam: implement it to put a KMS, Key Vault or HSM behind the mapping. AesGcmDocumentEncryptor is the in-process implementation — AES-256-GCM (authenticated, so a tampered value throws instead of decrypting to garbage), with a key ring so old documents stay readable while new writes use the current key.

var key = AesGcmDocumentEncryptor.GenerateKey(); // 32 bytes — store it somewhere real

The stored envelope is enc:<version>:<keyId>:<base64>, so every value says which key it needs. Rotation is four steps, in this order:

// 1. Add the new key alongside the old one, and make it current
var encryptor = new AesGcmDocumentEncryptor("k2", new Dictionary<string, byte[]>
{
["k1"] = oldKey,
["k2"] = newKey
});
// 2. Documents written under k1 still read — the envelope names their key
// 3. Move them across
await store.RewrapAsync<Patient>();
// 4. Only now retire k1

Retiring a key before the rewrap makes every document still holding an old envelope unreadable — the error says which key id it wanted.

RewrapAsync<T> reads a page at a time and writes back with BatchUpdate; reading decrypts under whichever key the envelope names and writing re-encrypts under the current one, so a plain round trip is the rewrap. It touches every document of the type — run it from a job, not a request.

Nothing tells you from the application side whether a RewrapAsync<T> finished — and retiring a key while documents are still under it makes those documents unreadable. The Admin UI answers exactly that: it reads the envelope, reports how many values sit under each key id, counts the ones that are still plaintext, and refuses to let an editor overwrite an envelope with clear text without saying so first.

It does all of that without any key. The envelope is self-describing, which is the point of it being a JSON string. Reading the values back is a separate, per-connection opt-in.

Tooling of your own can recognise an envelope the same way, through the public read-only contract:

if (DocumentEncryptionFormat.TryParse(value, out var info))
Console.WriteLine($"encrypted under {info.KeyId}");

DocumentEncryptionFormat never decrypts — it describes the shape, so a backup checker, a migration script or a repair job can tell an envelope from plaintext without holding key material.

A value that is not an envelope is read back as-is, so mapping a property on a populated store does not break its existing documents: they read as plaintext, and any document you write is encrypted. Convert the backlog with the same RewrapAsync<T> used for rotation.

Feature Behavior
Temporal history History rows hold ciphertext; History/AsOf decrypt on materialization.
Backup / bulk export Ciphertext moves verbatim. A restore into a store without the key ring stores unreadable values — it does not fail.
Replication / a second store Same: the target needs the same keys to materialize the property.
JSON collections Envelope passthrough, no decryption.
Raw JSON terminals Throw NotSupportedException naming the encrypted properties. Handing back the stored body would emit ciphertext on the zero-copy providers and plaintext on the rest — one call, two answers — so the lane refuses the type outright. Read it typed.
OData and the AI tools Return the decrypted value, matching Get<T>(). See “Encryption is at rest” below.
GetDiff / GetDiffBetween Compare the decrypted documents, so an unchanged encrypted property produces no operation.
Computed properties, full text, vectors Do not map them over an encrypted property — the engine only sees ciphertext.
Soft delete, interceptors, sessions Unaffected.
SQLCipher Complementary: SQLCipher encrypts the whole SQLite file at rest, this encrypts named properties on every provider. Use both if you want both.
AOT Clean. Every converter instantiation is explicit — no MakeGenericType.

Encryption is at rest — plan the wire separately

Section titled “Encryption is at rest — plan the wire separately”

A mapped property is ciphertext in the database. Everywhere else it is the value: Get<T>(), ToList(), the OData entity set and the AI tools all hand back "123-45-6789", because they all hand back a materialized document.

The reason this needs saying: the converters are symmetric — reading a stored body decrypts, writing encrypts. Anything that turns a materialized document back into JSON therefore has to serialize through a plaintext writer, or it re-encrypts what it just decrypted. Library code that does this uses DocumentEncryption.PlaintextView(...); if you serialize documents yourself with the store’s JsonSerializerOptions, use it too:

var patients = await store.Query<Patient>().ToList();
// wrong — re-encrypts every mapped property, with a new envelope each time under Randomized
var wrong = JsonSerializer.Serialize(patients, storeOptions);
// right
var json = JsonSerializer.Serialize(patients, DocumentEncryption.PlaintextView(storeOptions));

It returns the very same instance when nothing on those options is encrypted, so it costs nothing to use by default. It is a writer: point it at documents, never at a stored body — it does not decrypt.

  • One encryption mapping per property, process-wide: mapping the same property with a different mode or a different encryptor throws, because documents written under one would not decrypt under the other.
  • The property must be a direct property of the document type (x => x.Ssn). To encrypt something nested, map it on the nested type.
  • Collections, arrays and complex objects are not encryptable — hold the value as a string and convert it yourself.
  • The store’s JsonSerializerOptions must have a TypeInfoResolver (your JsonSerializerContext in AOT), and MapEncryptedProperty must be called while configuring the store, before the first read or write.