Transparent, column-level encryption for EF Core string properties, applied at the database boundary via a standard
ValueConverter.
Scope note: this page covers
src/EfCore/DKNet.EfCore.Encryptiononly — column-level encryption for EF Core entities. It is unrelated toDKNet.Svc.Encryption/DKNet.Svc.BlobStorage.Encryptionundersrc/Services, which are general-purpose application/blob cryptography packages with no EF Core dependency.
- Application code never sees ciphertext — mark a
stringproperty[Encrypted]and EF Core encrypts on the way to the database and decrypts on the way back. No repository, service, or query call site changes. - Column-level, not whole-database — protects the specific sensitive columns (SSNs, card numbers, tokens, PII) that need to be unreadable in raw database files, backups, and to anyone with direct SQL access, without adopting TDE for everything.
- Authenticated encryption by default —
AesGcmColumnEncryptionProvideruses AES-GCM with a random IV per call, so tampered ciphertext fails loudly instead of decrypting to garbage. - Key sourcing is yours, wiring is one call — implement
IEncryptionKeyProvider(Key Vault, KMS, config, whatever) and register it once withAddEfCoreEncryption<T>(); the model-build hook does the rest. - No dependency on the rest of the stack — the package references only
Microsoft.EntityFrameworkCore, so it drops into any EF Core project independent of the DKNet hook pipeline.
Do not reach for it when you need to filter, sort, or LIKE-search on the encrypted value in SQL — a random IV
per write makes encrypted columns opaque to the database. See
Gotchas & limits.
dotnet add package DKNet.EfCore.EncryptionThree steps get a property encrypted end-to-end:
a) Supply a key. Implement IEncryptionKeyProvider:
public sealed class AppEncryptionKeyProvider : IEncryptionKeyProvider
{
private readonly byte[] _key = Convert.FromBase64String(
Environment.GetEnvironmentVariable("APP_ENCRYPTION_KEY")!); // 16, 24, or 32 bytes
public byte[] GetKey(Type entityType) => _key;
}b) Register it in DI, via EfCoreEncryptionSetup.AddEfCoreEncryption<TKeyServiceImplementation>:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEfCoreEncryption<AppEncryptionKeyProvider>(); // registers IEncryptionKeyProvider as a singletonThe extension takes and returns IServiceCollection, so it is callable on builder.Services and chains with any other registration call.
c) Apply it in OnModelCreating via ModelBuilderExtensions.UseColumnEncryption, and mark the property:
public class Customer
{
public int Id { get; set; }
[Encrypted]
public string? Ssn { get; set; }
}
public class AppDbContext(DbContextOptions<AppDbContext> options, IEncryptionKeyProvider keyProvider)
: DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.UseColumnEncryption(keyProvider);
}
}From here, context.Customers.Add(new Customer { Ssn = "123-45-6789" }) and SaveChangesAsync() stores ciphertext; reading customer.Ssn back gives the plaintext.
A property-only marker attribute with no data — [AttributeUsage(AttributeTargets.Property)]. UseColumnEncryption scans every string property in the model and only touches ones decorated with it:
public class Employee
{
public int Id { get; set; }
[Encrypted]
public string? TaxId { get; set; }
public string Name { get; set; } = string.Empty; // untouched
}It is only inspected on properties whose CLR type is string; applying it to a non-string property has no effect (silently ignored, not an error).
The actual EF Core value converter doing the work — a thin ValueConverter<string?, string?> that delegates to an IColumnEncryptionProvider:
public sealed class ColumnEncryptionConverter(IColumnEncryptionProvider encryptionProvider)
: ValueConverter<string?, string?>(
v => encryptionProvider.Encrypt(v),
v => encryptionProvider.Decrypt(v));You normally never construct this yourself — UseColumnEncryption creates and applies one per matching property. You could still apply it manually to a single property for finer control:
modelBuilder.Entity<Customer>()
.Property(c => c.Ssn)
.HasConversion(new ColumnEncryptionConverter(new AesGcmColumnEncryptionProvider(key)));The encryption algorithm abstraction consumed by ColumnEncryptionConverter:
public interface IColumnEncryptionProvider
{
string? Decrypt(string? ciphertext);
string? Encrypt(string? plaintext);
}The package ships exactly one implementation (AesGcmColumnEncryptionProvider) and UseColumnEncryption hardcodes it — there's no DI slot to swap the algorithm for [Encrypted]-driven properties. To use a different provider you'd write your own model-building extension that builds ColumnEncryptionConverter with your IColumnEncryptionProvider instead of calling UseColumnEncryption.
AES-256/192/128-GCM (authenticated encryption). Constructor takes the raw key:
public AesGcmColumnEncryptionProvider(byte[] key) // key.Length must be 16, 24, or 32Encrypt(string? plaintext)— generates a random 12-byte IV per call, encrypts with AES-GCM, and returns Base64 ofIV (12 bytes) + Tag (16 bytes) + ciphertext. Null/empty input passes through unchanged (never encrypted).Decrypt(string? ciphertext)— reverses the packing; throwsArgumentExceptionif the Base64 payload is shorter thanIV + Tag(invalid format), orInvalidOperationExceptionif AES-GCM authentication fails (wrong key or corrupted/tampered data).
Because the IV is random per call, encrypting the same plaintext twice produces different ciphertext — this is by design (semantic security) but has query implications, see Gotchas.
public interface IEncryptionKeyProvider
{
byte[] GetKey(Type entityType);
}The package supplies no concrete key source — no config binding, no Key Vault client, nothing that reads a connection string or secret store for you. You always write the implementation and decide where the bytes come from (environment variable, IConfiguration, Azure Key Vault SDK, a secrets file, etc.), by implementing this interface directly. There is no abstract base class to derive from.
GetKey receives the entity's CLR type (the property's DeclaringType), not the property name — so you can vary keys per entity type, but every [Encrypted] property on the same entity shares one key.
public static void UseColumnEncryption(this ModelBuilder modelBuilder, IEncryptionKeyProvider encryptionKeyProvider)Called once from OnModelCreating. For every string property across every entity type in the model that carries [Encrypted]:
- Throws
InvalidOperationExceptionif the property is a primary key or foreign key column (encrypting join/identity columns is unsupported). - Otherwise calls
encryptionKeyProvider.GetKey(propertyInfo.DeclaringType), buildsnew ColumnEncryptionConverter(new AesGcmColumnEncryptionProvider(key)), and callsproperty.SetValueConverter(converter).
Throws ArgumentNullException if either argument is null.
public static IServiceCollection AddEfCoreEncryption<TKeyServiceImplementation>(this IServiceCollection services)
where TKeyServiceImplementation : class, IEncryptionKeyProviderRegisters TKeyServiceImplementation as the singleton IEncryptionKeyProvider, but only if one isn't already registered (idempotent — safe to call more than once, or alongside a manual registration you added yourself).
There is no options/settings class and no appsettings.json binding shipped by this package — the only
"configuration" surface is the IEncryptionKeyProvider implementation you write, and the constructor argument to
AesGcmColumnEncryptionProvider.
| Knob | Type | Default | Effect |
|---|---|---|---|
IEncryptionKeyProvider.GetKey(Type entityType) |
byte[] |
none — you must implement it | Supplies the AES key for every [Encrypted] property on that entity type. Evaluated once per property at model-build time. |
AesGcmColumnEncryptionProvider(byte[] key) |
byte[] |
none — required | Key material. Must be exactly 16, 24, or 32 bytes; anything else throws ArgumentException, null throws ArgumentNullException. |
AddEfCoreEncryption<TKeyServiceImplementation>() |
IServiceCollection extension |
— | Registers TKeyServiceImplementation as the singleton IEncryptionKeyProvider, only if one is not already registered. |
ModelBuilderExtensions.UseColumnEncryption(...) |
ModelBuilder extension |
— | Must be called from OnModelCreating; without it, [Encrypted] has no effect. |
Behaviour worth knowing beyond the table:
- Key source: entirely up to your
IEncryptionKeyProvider; nothing is read from configuration automatically. - Key length: must be exactly 16, 24, or 32 bytes (AES-128/192/256);
AesGcmColumnEncryptionProvider's constructor throwsArgumentExceptionotherwise, andArgumentNullExceptionfor a null key. - Key granularity: one key per entity CLR type (
GetKey(Type)), applied to all of that entity's[Encrypted]properties — no built-in per-property key. - Key rotation: not implemented by the package.
GetKeyis evaluated once per property, at model-building time (effectively once perDbContexttype per application lifetime, since EF Core caches the compiled model). Rotating a key means: change what yourIEncryptionKeyProviderreturns, restart the app (or otherwise force EF Core to rebuild the model), and run a data migration that decrypts existing rows with the old key and re-encrypts with the new one — there's no dual-key/versioned-ciphertext support built in. - Nullable handling: both
EncryptandDecryptshort-circuit onnull/empty string and return the input unchanged — empty/null values are never turned into ciphertext.
The whole package is one value converter wired up at model-build time — which is exactly why a key change needs a model rebuild and why the database never sees plaintext:
- Built directly on EF Core's own
Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<TModel,TProvider>— the package's.csprojhas exactly onePackageReference,Microsoft.EntityFrameworkCore. It does not depend onDKNet.EfCore.Abstractions,DKNet.EfCore.Extensions, orDKNet.EfCore.Hooks. - It is a model-build-time mechanism (a
ValueConverterapplied inOnModelCreating), not aSaveChangesinterceptor/hook — it does not participate in theDKNet.EfCore.Hookspipeline and has no interaction withIHook/SaveChangesAsyncinterception. [Encrypted]and every other type documented above live entirely insideDKNet.EfCore.Encryption— there's no dependency on (or hook into)DKNet.EfCore.Abstractionsfor model-building.- Because it's just another
SetValueConvertercall, it composes with any other EF Core model configuration on the same property as long as nothing else also callsSetValueConverter/HasConversionon that property afterward (last write wins — don't double-convert the same column). - It coexists independently with
DKNet.EfCore.AuditLogsandDKNet.EfCore.DataAuthorizationon the sameDbContext— they operate on different model/pipeline surfaces (auditing/authorization are SaveChanges/query-filter concerns; this is a value-conversion concern) and don't need explicit ordering relative to each other.
- Encrypted columns are opaque to SQL.
AesGcmColumnEncryptionProvideruses a random IV per encryption call, so the same plaintext produces different ciphertext every time.context.Customers.Where(c => c.Ssn == "123-45-6789")translates to a SQL comparison against ciphertext and will not match — equality,LIKE,ORDER BY, and indexes on encrypted columns are all unusable. Decrypt-and-compare in memory, or maintain a separate deterministic value (e.g., a HMAC hash column) if you need to search by an encrypted field. - Primary/foreign keys can't be encrypted.
UseColumnEncryptionthrowsInvalidOperationExceptionat model-build time if[Encrypted]is applied to a PK or FK property. - Only
stringproperties are considered. The scan filters onClrType == typeof(string);[Encrypted]on any other type is silently ignored — no compile or runtime error tells you it wasn't applied. - One key per entity type, not per property.
GetKey(Type entityType)gives you the declaring entity type only; two[Encrypted]properties on the same entity always share the same key. - No built-in key rotation or ciphertext versioning. Changing the key requires an explicit re-encryption migration; there's no support for decrypting old ciphertext with a previous key while encrypting new writes with a new one.
- Ciphertext is longer than plaintext. Stored value is Base64 of
12-byte IV + 16-byte tag + ciphertext, so anvarchar/varcharcolumn must be sized with headroom (roughly plaintext-bytes + 28, then ×~1.33 for Base64) or migrations/writes can truncate. - Corrupted or wrong-key ciphertext throws, not silently returns garbage.
DecryptthrowsArgumentExceptionfor a malformed payload andInvalidOperationExceptionwhen AES-GCM authentication fails (tampered data or wrong key) — handle these at the boundary if a bad key/rotation could reach production data. - Not the same package as
DKNet.Svc.Encryption/DKNet.Svc.BlobStorage.Encryption. Those are general-purpose cryptography utilities undersrc/Serviceswith no EF Core dependency; use this package specifically for EF Core column-level encryption.
- DKNet.EfCore.Abstractions – declares
[SensitiveData], which controls whether a value is shown in an audit trail. Reach for it to hide a value from logs; reach for this package to protect it at rest. - DKNet.EfCore.AuditLogs – records field-level changes. Reach for it when you need a history of who changed a column, remembering that an audit entry captures the plaintext value unless the property is marked sensitive.
- DKNet.EfCore.DataAuthorization – row-level access control. Reach for it to decide which rows a caller may read; this package decides whether a stored column is readable at all.
- DKNet.EfCore.Extensions – the model-building/wiring layer for the rest of the stack.
Reach for it for automatic entity configuration; this package's
UseColumnEncryptionis an independentOnModelCreatingcall.