Skip to content

Field-Level Encryption — AES & Vault Keys

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.

  • DirectoryGranit.Encryption/ IStringEncryptionService, AES-256-CBC default
    • DirectoryGranit.Encryption.EntityFrameworkCore/ [Encrypted] attribute, EncryptedStringConverter, ApplyEncryptionConventions
    • DirectoryGranit.Encryption.ReEncryption/ IReEncryptionJob, DefaultReEncryptionJob
PackageRoleDepends on
Granit.Encryption.EntityFrameworkCore[Encrypted] attribute, EncryptedStringConverter, ApplyEncryptionConventions extensionGranit.Encryption, Granit.Persistence
Granit.Encryption.ReEncryptionIReEncryptionJob, DefaultReEncryptionJob<TContext>, AddGranitEncryptionReEncryption<TContext>()Granit.Encryption.EntityFrameworkCore
  1. Add the NuGet package

    Terminal window
    dotnet add package Granit.Encryption.EntityFrameworkCore

    If you also need the re-encryption job:

    Terminal window
    dotnet add package Granit.Encryption.ReEncryption
  2. Register the module

    [DependsOn(
    typeof(GranitEncryptionEntityFrameworkCoreModule),
    typeof(GranitVaultHashiCorpModule))] // or GranitEncryptionModule for AES
    public sealed class AppModule : GranitModule { }
  3. Configure encryption

    {
    "Encryption": {
    "ProviderName": "Vault",
    "VaultKeyName": "pii-data"
    }
    }

    See Vault & Encryption for full provider configuration.

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.

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.

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);
}
}

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.

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.

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;
}
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
[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.

public sealed class EncryptedStringConverter(IStringEncryptionService encryptionService)
: ValueConverter<string, string>;

The EF Core value converter. ConvertToProvider calls Encrypt(plainText). ConvertFromProvider calls Decrypt(cipherText) ?? string.Empty.

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].

public interface IReEncryptionJob
{
Task ReEncryptAsync<TEntity>(
int batchSize = 500,
CancellationToken cancellationToken = default)
where TEntity : class;
}
public static IServiceCollection AddGranitEncryptionReEncryption<TContext>(
this IServiceCollection services)
where TContext : DbContext;

Registers IReEncryptionJobDefaultReEncryptionJob<TContext> (scoped). Requires IDbContextFactory<TContext> to be already registered.

RegulationRequirementCoverage
GDPR Art. 5Data integrity and confidentialityCiphertext stored, plaintext never persisted
GDPR Art. 25Data protection by designEncryption enforced at model level, not call site
ISO 27001 A.10.1Cryptographic key managementKey rotation via Vault, key retirement via RetiredKeyVersions

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 — IReEncryptionJob handles 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

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");
}