Encryption happens in the EF value converter, inside your application. The database stores ciphertext it cannot read, and the library makes no network calls of any kind — no phone-home, no telemetry, no key escrow, no vendor infrastructure in the path.
AES-GCM produces a 128-bit authentication tag alongside the ciphertext. Tampering, bit-flipping, a swapped value from another row, or the wrong key are all detected and raise an error — rather than quietly decrypting into plausible-looking garbage.
[version:1][keyId:1][nonce:12][tag:16][ciphertext:n]
Thirty bytes of envelope. The version byte allows the format to evolve without breaking stored data; the key id is what makes rotation possible.
Each value gets its own 96-bit random nonce, so encrypting the same Aadhaar number twice produces completely different ciphertext. Without that, anyone reading the table learns which rows share a value — before breaking anything at all.
Nonces are drawn from the system CSPRNG in blocks for speed. Same source, same distribution, same collision bounds.
Every encrypted value carries the id of the key that produced it. Register the new key as primary, keep the old one for reading, and re-encrypt at whatever pace suits you.
var provider = new AesGcmCryptoProvider( primaryKey: new AesGcmKey(keyId: 2, currentKey), // every new write decryptionKeys: [new AesGcmKey(1, previousKey)]); // old rows still readable
Touching a value rewrites it through the converter under the new key. Sweep in batches, on your schedule, then retire the old key from the ring.
Optional associated data is mixed into every authentication tag. Values encrypted under one tenant identifier cannot be decrypted under another, even with the right key.
MigrationCryptoProvider writes GCM while still reading legacy CBC rows. The auth tag makes
mis-routing cryptographically impossible, so the bridge is safe to run in production.
Randomized encryption is the strongest option and the least queryable. There is no scheme that gives you both — anything that lets the database match on a value necessarily reveals that two rows match. Here is the whole menu.
| Approach | == value in SQL |
Tamper-evident | What it reveals |
|---|---|---|---|
| Randomized AES-GCM
the default
|
No | Yes | Nothing beyond length. |
| Blind index
recommended for lookups
|
Yes — index seek | Yes | Equality, confined to a separate keyed column. |
| Deterministic AES-GCM | Yes — index seek | Yes | Equality, on the column itself. |
| Leave it plaintext | Yes, plus LIKE and sorting |
No | Everything, to anyone with database access. |
A keyed HMAC of the value in an indexed shadow column. The sensitive column stays randomized; only the derived column is comparable, and attacking it requires a second key you keep separately.
// model modelBuilder.Entity<User>().Property(u => u.Email).HasBlindIndex(); // query — a real index seek, not a table scan var user = await context.Users .WhereBlindEquals(nameof(User.Email), email, hasher) .FirstOrDefaultAsync();
Same plaintext, same ciphertext, so ordinary LINQ equality translates to SQL. The nonce is derived from the value itself — a synthetic IV, the construction AES-GCM-SIV uses — keyed with a value derived from your encryption key.
Fixing a GCM nonce across different plaintexts would leak the authentication subkey and permit forgery. Deriving it per value means two different values never share a nonce, so that attack does not arise, and the tag still authenticates.
new AesGcmCryptoProvider(key, deterministic: true);
LIKE, sorting or ranges. Ciphertext preserves neither prefixes nor order.
If a column needs those, it needs to be plaintext and protected another way.
There is no code path that encrypts without a provider you constructed with your key. The
GenerateKey() helpers are conveniences the library itself never calls, and nothing is written
to disk or transmitted.
Licences are signed keys verified offline with ECDSA P-256 against a public key embedded in the package. No network call, no machine fingerprinting, no usage reporting — an air-gapped deployment behaves identically.
Bad configuration throws at startup. An unlicensed production deployment refuses to start rather than quietly dropping paid features. Undeliverable alerts are retried, then dead-lettered and counted in the compliance report.
The scanner samples column data to classify it but never logs a sampled value — findings carry match rates and evidence descriptions, not the data itself. Alert emails and reports HTML-encode every field they render.
An unlicensed production deploy should be caught at startup, not discovered in an audit. Detection reads
environment variables and container markers only — KUBERNETES_SERVICE_HOST,
DOTNET_RUNNING_IN_CONTAINER, /.dockerenv, cgroups, and the Azure / AWS / GCP /
Heroku variables. No metadata endpoint is contacted, ever.
| Mode | Local development | Container / Kubernetes / managed cloud |
|---|---|---|
| Auto
the default
|
Warns once, degrades to Community | Startup fails |
| Strict | Startup fails | Startup fails |
| WarnOnly | Warns once | Warns once |
A hosted service registered first validates the licence, so a bad key aborts host startup before a single request is served.
EFCRYPTOML_LICENSE_ENFORCEMENT=WarnOnly unblocks an incident at 3am. It is logged, and you can
switch it off with AllowEnvironmentOverride.
The core runs in-process and is bypassable by design. This is startup enforcement plus an audit trail to catch accidental unlicensed deploys — nothing more.
The cryptography is a few hundred lines and it is worth twenty minutes of your security reviewer's time. That is a better basis for adopting it than any claim on this page.