Data & persistence · No. 46
Field-Level Encryption in EF Core: AES-GCM for PII Columns

Transparent database encryption protects the file, then decrypts for anyone who can query it. When a column holds data sensitive enough that a database reader should still see ciphertext, you need a second layer: an AES-256-GCM EF Core value converter that encrypts a PII column per property, formalized in ADR-037.
You turned on Transparent Data Encryption (TDE) and closed the "data at rest" line item. The database
files are encrypted, the backups are encrypted, the audit checkbox is green. Then a backup gets restored
onto a host it should never have touched, or an over-privileged reporting connection runs a SELECT, and
every email address, every phone number, every national ID comes back in plaintext.
That is not a TDE bug. That is TDE working exactly as designed. TDE encrypts the pages on disk and decrypts them transparently for any principal that can open the database. Its threat model is a stolen disk, not a stolen query. The moment a reader is inside the database boundary, the whole point of the control has already been served, and the data is clear.
Some columns are sensitive enough to deserve a second, tighter boundary: the value should be ciphertext the instant it leaves the application, so that only a holder of the application key, not merely a database reader, can turn it back into text. In MMCA.Common that boundary is one EF Core value converter.
Why it matters
The gap between "the database is encrypted" and "this column is protected" is where compliance findings live. A PII inventory (rubric §30 asks for exactly this: what is collected, where it is stored, who can access it) will list columns whose readership you cannot actually constrain to your application. A DBA, a replicated read model, a support tool with a broad connection, a restored backup on a laptop: each is a legitimate database reader, and under TDE alone each sees plaintext.
Rubric §11 makes the same demand from the attacker's side: data-at-rest protection and PII handling as a correctness property, not a deployment afterthought. The two categories meet on one column. §11 defends against whoever should not have the data; §30 answers to the regulator who asks where the data is and who can read it. Field-level encryption is a single control that speaks to both, because it moves the trust boundary from "can reach the database" to "holds the application key."
This is a distinct control from the two sensitive-data mechanisms the framework already records. Password hashing (ADR-032) is deliberately one-way: a credential is stored so it can be verified, never recovered. Erasure (ADR-005) removes or anonymizes personal data on a data-subject request. Neither covers the case of a field that must stay retrievable in plaintext to the application yet be unreadable in the database itself. That is the case ADR-037 answers: an at-rest, reversible, column-level confidentiality control.
The MMCA answer: one value converter, applied per property
The mechanism is EncryptedStringConverter, a sealed ValueConverter<string, string> in the
MMCA.Common.Infrastructure.Persistence.Encryption namespace. You attach it to a single property in an
EF entity configuration, and from that point on the property encrypts on write and decrypts on read.
Application code, domain code, DTOs, handlers: all of them keep working with an ordinary string and
never see ciphertext.
// Wire the converter onto the sensitive column in the entity configuration.
// (Illustrative: the first HasConversion call is the converter's own documented usage,
// the second paraphrases the key-ring constructor, and the layout comment
// describes what Encrypt actually writes.)
builder.Property(e => e.SocialSecurityNumber)
.HasConversion(new EncryptedStringConverter(encryptionKey)); // encryptionKey: 32 bytes
// The same converter over a ring of versioned keys, writing under version 2.
builder.Property(e => e.SocialSecurityNumber)
.HasConversion(new EncryptedStringConverter(
new Dictionary<byte, byte[]> { [1] = retiringKey, [2] = currentKey },
currentKeyVersion: 2));
// On write, the stored value is Base64 of:
// [key version (1)] [nonce (12)] [ciphertext (N)] [tag (16)]
// On read, the version byte picks the key, and the tag (which covers that byte) is verified.
Four properties of that converter are worth naming precisely, because they are the whole design.
It is authenticated, not merely confidential. Both directions use AesGcm, AES-256 in Galois/Counter
Mode, which binds a 128-bit authentication tag to the ciphertext. Encryption gives you confidentiality;
the tag gives you integrity for free. A value that has been tampered with, truncated, or corrupted fails
the tag check inside AesGcm.Decrypt and throws rather than silently returning garbage plaintext. The
converter also rejects, up front, any stored value too short to even hold a key version, a nonce and a
tag, throwing a CryptographicException before it ever calls into the primitive.
Every key is exactly 256 bits, and the converter refuses anything else. There are two constructors.
The single-key one takes a raw byte[], null-checks it with ArgumentNullException.ThrowIfNull, and
throws ArgumentException unless the key is exactly 32 bytes (256 bits); it is sugar for a one-entry
key ring registered at version 1. The other takes an IReadOnlyDictionary<byte, byte[]> key ring plus
the version to write with, and validates the whole thing once at construction: the ring must be non-null
and non-empty, no entry may be null, every key must be exactly 32 bytes, and the nominated current
version must actually be present. The validated ring is then copied into a FrozenDictionary, so
mutating the dictionary you passed in cannot change which keys the converter uses afterwards. There is a
GenerateKey() helper that produces a cryptographically random 32-byte key via
RandomNumberGenerator.GetBytes(32) for initial setup. The envelope sizes are fixed constants: a 1-byte
key version, a 12-byte nonce (96 bits, the size NIST recommends for GCM) and a 16-byte tag (128 bits).
The storage envelope is versioned and self-describing, and the ciphertext is non-deterministic. On
write, Encrypt resolves the current key from the ring, takes the UTF-8 bytes of the plaintext, draws a
fresh random 12-byte nonce, runs AesGcm.Encrypt, then lays out
[key version] [nonce] [ciphertext] [tag] in one buffer and Base64-encodes it into the string column. On
read, Decrypt Base64-decodes, reads the version byte at position 0 to select the key, slices the three
remaining regions back out by their fixed offsets using spans, and calls AesGcm.Decrypt, which validates
the tag while decrypting. Because the nonce is fresh on every write, the same plaintext encrypts to
different ciphertext every single time. That is the correct confidentiality default: a deterministic
scheme would leak equality and frequency over the column, and this one does not.
The version byte is authenticated, and that makes rotation a supported operation. The version is not
merely stored next to the ciphertext, it is passed to AES-GCM as associated data on both encrypt and
decrypt, so the authentication tag covers it. Rewriting the version byte of a stored value therefore fails
the tag check instead of quietly redirecting decryption to a different key, and it fails even when the
substituted version happens to map to the same key: a test registers one key under two versions, flips the
byte, and still gets a CryptographicException. Because writes always stamp the current version while
reads take their key from the data, rotation is a four-step operation with no maintenance window: add the
new key to the ring and make it current while keeping the old version registered, deploy (new writes carry
the new version, existing rows keep decrypting under theirs), re-encrypt the old rows in the background at
whatever pace the table allows, then drop the retired version from the ring. A value whose version is no
longer registered throws a CryptographicException naming only the version number, never key material,
which is the loud failure rather than the silent one.
There is one deliberate passthrough: both directions short-circuit on a null-or-empty string, so a NULL or empty column stays as-is rather than becoming a block of ciphertext for the empty string.
Where do the keys come from? Not from the framework. The converter takes raw key material as a
constructor argument, one byte[] or a whole ring, and stops there: no DI registration, no options type,
no key-provider abstraction in Infrastructure, and no automatic refresh, since the ring is frozen at
construction and adding a version means constructing a new converter. The adopting entity configuration
supplies the keys, and the converter's own XML documentation is explicit that they belong in Azure Key
Vault, user-secrets, or an environment variable, and never hardcoded. That lines up with rubric §11's
secrets criterion (in a vault or managed identity, never in source or plain config), and it keeps the
crypto primitive free of any opinion about your secret store.
It is also stateless and context-free on purpose. Version resolution is data-driven from the stored
envelope and never consults the DbContext, because a value converter is a pair of compiled expressions
running inside the provider's materialization path: it cannot reach the context, the current user, or any
ambient request scope. Per-tenant and per-request key selection are therefore deliberately out of scope
here; that shape wants a SaveChanges interceptor or application-layer encryption above EF Core, where
the request context is still reachable.
The honest part: shipped, tested, latent
Here is what separates this from a "look what we built" post. This converter is not in production. Zero
columns across the four repositories are encrypted with it today. The plumbing is complete and the
encrypt/decrypt round-trip, the tag-validated integrity path, the 32-byte key guard on both construction
paths, the four ring-validation guards, the defensive copy of the caller's dictionary, the version byte
each write stamps, a full rotation round-trip, an unregistered version, a rewritten version byte, the
empty-string passthrough, and the too-short-ciphertext rejection are all exercised by
EncryptedStringConverterTests (21 cases), but no *Configuration.cs in
any repo calls .HasConversion(new EncryptedStringConverter(...)). ADR-037 records that posture in the
open: the capability is proven by tests, not by any deployed column.
That is deliberate. The framework's job is to decide the algorithm, the key size, the nonce size, and the storage envelope once, in a single shared type, so that the first team to adopt it inherits a reviewed primitive instead of hand-rolling AES-GCM in a module. ADR-005 already names this converter as the mechanism for erasure fields "that must remain retrievable," so the pairing is designed and available. It is not yet wired. Telling you that is the point: a security control you claim but have not adopted is worse than one you have honestly labeled latent.
The zero-adoption posture is also what paid for the versioned envelope. A layout of nonce, ciphertext and tag with no key identifier leaves the obvious question unanswered: what happens when the key has to change. That answer belongs in the storage format rather than in a paragraph explaining the gap, which is why the envelope leads with a key-version byte and the converter reads its key from the data. There is no legacy-decode path and there will not be one: a value in an un-versioned form does not read back under this converter, because its first byte is a nonce byte rather than a version. That break is free precisely because no column stores one. The window in which a storage format is free to change closes at the first adopted column, which is exactly why the format is settled now rather than after adoption.
Column-level crypto vs TDE: the trade you are actually making
TDE and field-level encryption are not competitors; they are different threat models, and the honest framing is when each earns its cost.
TDE is cheap, global, and transparent to queries. It encrypts everything at rest with no application changes and no queryability loss, and it protects against exactly one thing: someone walking off with the storage. If your risk is a stolen disk or an unencrypted backup, TDE is the whole answer.
Field-level encryption is targeted, and you pay for the targeting. It moves the trust boundary inside the
database, so a database reader without the application key sees ciphertext, but that same non-determinism
that makes it strong also makes the column unqueryable. You cannot equality-filter, index-seek, sort, or
join on an encrypted column, because the same plaintext never produces the same bytes twice. A field that
must be both encrypted and looked up needs a separate deterministic scheme or a blind index, and this
converter provides neither. It is the right control for a genuinely sensitive column the application
reads by primary key, not by the encrypted value itself. That is why the converter's own shipped usage
example targets a stored-only field rather than a login lookup: the XML documentation spells out that a
Where against an encrypted column compares to a ciphertext that will never match and returns no rows
silently, so encrypting an address the authentication flow queries by value would have broken sign-in
quietly instead of loudly.
Trade-offs, honestly
- Encrypted columns are not queryable. The random nonce makes ciphertext non-deterministic, so there is no equality filter, index seek, sort, or join on an encrypted column. Encrypt the columns you read by key, not the ones you search by value.
- Key management is still entirely yours; the ring is a mechanism, not a service. The converter holds whatever key material you hand it, frozen at construction. There is no key-provider abstraction, no Key Vault integration, no automatic refresh, and no envelope encryption over a key-encryption key. Losing a key still makes every row written under that version permanently unrecoverable; the ring makes that failure mode more granular, not less likely.
- Rotation is enabled, not automated. The versioned envelope makes a zero-downtime rotation possible,
but the re-encryption pass is yours to write and to run, and nothing in the framework reports how many
rows still carry an old version. Retire a version early and every unconverted row throws on read: loud,
but still an outage for that column. The version prefix is one
byte, so a ring caps at 256 live versions, which is ample for annual or quarterly rotation and a real ceiling for anything faster. - Per-property wiring, not a global switch. Encryption is one
HasConversioncall at a time in each entity configuration. A column that should be encrypted but is never wired stays silently plaintext, so the control is only as good as the audit of your PII inventory (which is why §30 wants that inventory in the first place). - Storage and CPU overhead. Every value grows by 29 bytes (a 1-byte key version, a 12-byte nonce, and a 16-byte tag) before Base64 inflation, and every read and write performs an AES-GCM operation.
- Latent until adopted. Proven by
EncryptedStringConverterTests, not by production. The round-trip works; the decision to encrypt a specific column is still yours to make and to audit.
None of these are reasons to skip encrypting genuinely sensitive columns. They are the reasons to encrypt deliberately: the columns you read by key, with a key lifecycle you have designed, tracked against a PII inventory you actually keep.
Apply this even without MMCA
The pattern ports to any EF Core codebase, and to most ORMs with a value-conversion or type-handler hook:
- Use an EF Core
ValueConverter<string, string>(or your ORM's equivalent) so encryption lives on the property mapping and the rest of the code keeps a plainstring. - Choose an authenticated cipher (AES-GCM), not a bare block mode, so integrity comes with confidentiality and tampering fails loudly instead of decrypting to garbage.
- Store the nonce with the ciphertext in a self-describing envelope
(
[key version][nonce][ciphertext][tag], Base64 into a string column) and draw a fresh random nonce per write so the same plaintext never repeats its ciphertext. - Put a key version in the envelope on day one, and authenticate it. Pass the version as associated data so the tag covers it: a self-describing envelope whose description can be rewritten is an invitation to rewrite it. One byte buys you both a rotation path and tamper detection on the version itself.
- Keep the keys out of the code. Take them from a vault, managed identity, user-secrets, or an environment variable, and design the rotation drill (add the new version as current, deploy, re-encrypt in the background, retire the old version) before you encrypt the first column, because the format stops being free to change the moment a row uses it.
- Encrypt by threat model. Column-level crypto is a second boundary above TDE for the few columns a database reader should never see in the clear, and you trade away queryability to get it.
The takeaway: TDE protects the file from whoever steals the disk; field-level encryption protects the column from whoever can run a query. Pick the columns you read by key, encrypt them with an authenticated cipher, a fresh nonce per write, and an authenticated key version in the envelope, keep the keys in a vault, and know going in that you are trading queryability for a trust boundary the database itself cannot cross.
What we covered: why TDE leaves a plaintext gap for any principal who can query the database, how
EncryptedStringConverter closes it per column with AES-256-GCM authenticated encryption, its
self-describing [key version][nonce][ciphertext][tag] envelope and non-deterministic ciphertext, the
key ring that makes a zero-downtime rotation possible and the authenticated version byte that keeps it
honest, the 32-byte key guard and vault-only key handling, the honest "shipped, tested, not yet adopted"
posture from ADR-037, and the trade of queryability for a trust boundary the database cannot cross.
Previously: Article 45, "Feature Flags in the CQRS Pipeline: Gate Commands, Not Code."
Next in the series: Article 47, "Security Headers and CSP for Blazor: One Middleware, Every Host," where the data-protection story moves from the column to the response.
Full reading order: the MMCA.Common series index (Article 50).
MMCA.Common is Apache-2.0 licensed and open source. Star the repo, read the persistence chapter of the
onboarding guide, or dotnet add package MMCA.Common.Infrastructure and try it.
Tags: .NET, C Sharp, Encryption, Data Security, EF Core