Field-Level Encryption — AES & Vault Keys
Why field-level encryption?
Section titled “Why field-level encryption?”Regulations like GDPR (Art. 32) and ISO 27001 (A.8.24) require encryption of sensitive data at rest. Full-disk or database-level encryption protects against stolen disks, but not against SQL injection, backup leaks, or unauthorized database access by internal users — the data is decrypted the moment it is read. Field-level encryption closes this gap: individual columns (national IDs, medical records, bank accounts) are encrypted with application-managed keys, so even a full database dump is useless without the key.
The challenge is doing this without polluting every repository call with
Encrypt()/Decrypt() logic. Granit makes it transparent: add [Encrypted] to a
property, and EF Core handles the rest — encryption on write, decryption on read, no
manual calls. When compliance requires key rotation, IReEncryptionJob re-encrypts
all marked fields in batch without downtime.
Package structure
Section titled “Package structure”DirectoryGranit.Encryption/ IStringEncryptionService, AES-256-CBC default
DirectoryGranit.Encryption.EntityFrameworkCore/ [Encrypted] attribute, EncryptedStringConverter, ApplyEncryptionConventions
- …
DirectoryGranit.Encryption.ReEncryption/ IReEncryptionJob, DefaultReEncryptionJob
- …
| Package | Role | Depends on |
|---|---|---|
Granit.Encryption.EntityFrameworkCore | [Encrypted] attribute, EncryptedStringConverter, ApplyEncryptionConventions extension | Granit.Encryption, Granit.Persistence |
Granit.Encryption.ReEncryption | IReEncryptionJob, DefaultReEncryptionJob<TContext>, AddGranitEncryptionReEncryption<TContext>() | Granit.Encryption.EntityFrameworkCore |
-
Add the NuGet package
Terminal window dotnet add package Granit.Encryption.EntityFrameworkCoreIf you also need the re-encryption job:
Terminal window dotnet add package Granit.Encryption.ReEncryption -
Register the module
[DependsOn(typeof(GranitEncryptionEntityFrameworkCoreModule),typeof(GranitVaultHashiCorpModule))] // or GranitEncryptionModule for AESpublic sealed class AppModule : GranitModule { } -
Configure encryption
{"Encryption": {"ProviderName": "Vault","VaultKeyName": "pii-data"}}See Vault & Encryption for full provider configuration.
Marking properties for encryption
Section titled “Marking properties for encryption”Add [Encrypted] to any string property on an EF Core entity:
using Granit.Encryption.EntityFrameworkCore;
namespace MyApp.Domain;
public sealed class Patient{ public Guid Id { get; set; } public string LastName { get; set; } = string.Empty;
[Encrypted] public string NationalId { get; set; } = string.Empty; // stored as ciphertext
[Encrypted] public string MedicalRecordNumber { get; set; } = string.Empty; // stored as ciphertext}LastName is stored in plaintext. NationalId and MedicalRecordNumber are
encrypted at write and decrypted at read — transparently.
Wiring up the converter
Section titled “Wiring up the converter”Call ApplyEncryptionConventions at the end of OnModelCreating, after
ApplyGranitConventions:
using Granit.Encryption.EntityFrameworkCore.Extensions;using Granit.Persistence;
namespace MyApp.Infrastructure;
public sealed class AppDbContext( DbContextOptions<AppDbContext> options, ICurrentTenant? currentTenant, IDataFilter? dataFilter, IStringEncryptionService encryptionService) : DbContext(options){ public DbSet<Patient> Patients => Set<Patient>();
protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ApplyGranitConventions(currentTenant, dataFilter); modelBuilder.ApplyEncryptionConventions(encryptionService); // must be last }}ApplyEncryptionConventions scans every entity type registered in the model,
finds string properties annotated with [Encrypted], and attaches a single
shared EncryptedStringConverter instance to each one.
Reading and writing encrypted fields
Section titled “Reading and writing encrypted fields”No code changes are required in services. Entity properties contain plaintext values in memory at all times. The conversion to and from ciphertext happens automatically at the EF Core boundary.
public sealed class PatientService(AppDbContext db){ public async Task CreateAsync( string lastName, string nationalId, CancellationToken cancellationToken) { db.Patients.Add(new Patient { LastName = lastName, NationalId = nationalId, // plaintext in code, ciphertext in DB MedicalRecordNumber = "MRN-001" // same });
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); }
public async Task<Patient?> FindByIdAsync(Guid id, CancellationToken cancellationToken) { // NationalId and MedicalRecordNumber are decrypted automatically on load return await db.Patients .FindAsync([id], cancellationToken) .ConfigureAwait(false); }}Key rotation and re-encryption
Section titled “Key rotation and re-encryption”When using Vault Transit, key rotation happens in Vault automatically. Old ciphertexts
remain readable (the key version is embedded in the vault:vN: prefix). To retire an
old key version, re-encrypt all rows first.
Running the re-encryption job
Section titled “Running the re-encryption job”Register the job alongside IDbContextFactory:
builder.Services.AddDbContextFactory<AppDbContext>(...);builder.Services.AddGranitEncryptionReEncryption<AppDbContext>();Inject IReEncryptionJob and run it — for example from a Wolverine scheduled job, a
hosted service, or a CLI command:
public sealed class KeyRotationJob(IReEncryptionJob reEncryption){ public async Task RunAsync(CancellationToken cancellationToken) { // Re-encrypt all Patient rows in batches of 500 await reEncryption .ReEncryptAsync<Patient>(batchSize: 500, cancellationToken) .ConfigureAwait(false);
// Repeat for every entity type with [Encrypted] properties await reEncryption .ReEncryptAsync<LegalAgreement>(batchSize: 500, cancellationToken) .ConfigureAwait(false); }}DefaultReEncryptionJob loads entities in batches, marks every [Encrypted] property
as modified, and saves. EF Core runs IStringEncryptionService.Encrypt on save —
producing a new ciphertext encrypted with the current key version. The job is
idempotent: running it multiple times is safe.
Blocking retired key versions
Section titled “Blocking retired key versions”Once all rows are re-encrypted, guard against accidental decryption with the old key by listing retired versions in configuration:
{ "ReEncryption": { "RetiredKeyVersions": ["v1", "v2"], "BatchSize": 500 }}Register ReEncryptionOptions via the standard options pattern:
builder.Services.Configure<ReEncryptionOptions>( builder.Configuration.GetSection(ReEncryptionOptions.SectionName));When HashiCorpVaultStringEncryptionProvider encounters a ciphertext prefixed with a
retired version (e.g., vault:v1:...), it throws RetiredKeyVersionException before
delegating to Vault — catching any row that was missed during re-encryption loudly and
early:
catch (RetiredKeyVersionException ex){ logger.LogError( "Ciphertext encrypted with retired key version {Version} — re-encrypt before retiring", ex.KeyVersion); throw;}Full key rotation workflow
Section titled “Full key rotation workflow”sequenceDiagram
participant SRE
participant Vault
participant Job as IReEncryptionJob
participant DB
SRE->>Vault: vault write transit/keys/pii-data/rotate
Vault-->>SRE: Key v3 created (v1, v2 still readable)
SRE->>Job: ReEncryptAsync<Patient>(batchSize: 500)
loop per batch
Job->>DB: Load batch (EF decrypts v1/v2 ciphertext)
Job->>DB: SaveChanges (EF encrypts with current v3)
end
Job-->>SRE: Done — all rows now at v3
SRE->>Vault: Add "v1", "v2" to RetiredKeyVersions
Note over DB: Any v1/v2 row missed → RetiredKeyVersionException thrown
API reference
Section titled “API reference”[Encrypted]
Section titled “[Encrypted]”[AttributeUsage(AttributeTargets.Property)]public sealed class EncryptedAttribute : Attribute;Marks a string EF Core property for transparent encryption at rest.
ApplyEncryptionConventions looks for this attribute at model-building time.
EncryptedStringConverter
Section titled “EncryptedStringConverter”public sealed class EncryptedStringConverter(IStringEncryptionService encryptionService) : ValueConverter<string, string>;The EF Core value converter. ConvertToProvider calls Encrypt(plainText).
ConvertFromProvider calls Decrypt(cipherText) ?? string.Empty.
ModelBuilder.ApplyEncryptionConventions
Section titled “ModelBuilder.ApplyEncryptionConventions”public static ModelBuilder ApplyEncryptionConventions( this ModelBuilder modelBuilder, IStringEncryptionService encryptionService);Scans all entity types registered in the model and applies EncryptedStringConverter
to every string property decorated with [Encrypted].
IReEncryptionJob
Section titled “IReEncryptionJob”public interface IReEncryptionJob{ Task ReEncryptAsync<TEntity>( int batchSize = 500, CancellationToken cancellationToken = default) where TEntity : class;}AddGranitEncryptionReEncryption<TContext>
Section titled “AddGranitEncryptionReEncryption<TContext>”public static IServiceCollection AddGranitEncryptionReEncryption<TContext>( this IServiceCollection services) where TContext : DbContext;Registers IReEncryptionJob → DefaultReEncryptionJob<TContext> (scoped).
Requires IDbContextFactory<TContext> to be already registered.
Compliance
Section titled “Compliance”| Regulation | Requirement | Coverage |
|---|---|---|
| GDPR Art. 5 | Data integrity and confidentiality | Ciphertext stored, plaintext never persisted |
| GDPR Art. 25 | Data protection by design | Encryption enforced at model level, not call site |
| ISO 27001 A.10.1 | Cryptographic key management | Key rotation via Vault, key retirement via RetiredKeyVersions |
When to use — and when not to
Section titled “When to use — and when not to”Use field-level encryption when:
- You store PII (national IDs, medical records, bank accounts) that regulations require to be encrypted at rest
- You need column-level protection — even DBAs or leaked backups should not see plaintext
- Key rotation is a compliance requirement —
IReEncryptionJobhandles this without downtime
Skip it when:
- Full-disk encryption (TDE, LUKS) is sufficient for your threat model — field-level adds overhead
- The data is non-sensitive (product catalogs, public content) — encryption adds latency on every read/write
- You need to query encrypted columns (WHERE, ORDER BY, LIKE) — encrypted values are opaque ciphertext
Common pitfalls
Section titled “Common pitfalls”Testing encryption
Section titled “Testing encryption”Use SQLite to verify encryption is applied (the value in the database should differ from the plaintext):
[Fact]public async Task Encrypted_field_is_not_stored_as_plaintext(){ // Arrange await using var context = CreateSqliteContext<PatientDbContext>(); var patient = new Patient { Niss = "85073000123" };
// Act context.Patients.Add(patient); await context.SaveChangesAsync();
// Assert — raw SQL shows ciphertext var raw = await context.Database .SqlQueryRaw<string>("SELECT Niss FROM Patients LIMIT 1") .FirstAsync(); raw.ShouldNotBe("85073000123");
// Assert — EF Core decrypts transparently var loaded = await context.Patients.FirstAsync(); loaded.Niss.ShouldBe("85073000123");}See also
Section titled “See also”- Crypto-Shredding — per-entity key isolation, GDPR erasure by key destruction
- Encrypt sensitive data guide — step-by-step walkthrough
- Vault & Encryption — provider configuration, key rotation,
ITransitEncryptionService - Privacy module — GDPR erasure patterns
- Persistence module — isolated
DbContext,ApplyGranitConventions