Encrypt Sensitive Data — AES & Vault Guide
Granit provides two encryption layers — field-level encryption via IStringEncryptionService
and Vault Transit encryption via ITransitEncryptionService — so sensitive data
(national ID numbers, health records, API keys) is never stored in plaintext.
Prerequisites
Section titled “Prerequisites”- A .NET 10 project with
Granit Granit.Encryptionfor AES-256 field-level encryptionGranit.Vaultfor vault abstractions (ITransitEncryptionService,IDatabaseCredentialProvider)Granit.Vault.HashiCorpfor HashiCorp Vault Transit encryption (production)Granit.Vault.Azurefor Azure Key Vault encryption (production, Azure environments)- A running HashiCorp Vault or Azure Key Vault instance (production only)
Step 1 — Install the packages
Section titled “Step 1 — Install the packages”dotnet add package Granit.Encryptiondotnet add package Granit.Vault.HashiCorpGranit.Vault.HashiCorp depends on Granit.Vault and Granit.Encryption — all are installed automatically.
dotnet add package Granit.Vault.AzureGranit.Vault.Azure depends on Granit.Vault and Granit.Encryption — all are installed automatically.
Uses DefaultAzureCredential (Managed Identity in production, az login locally).
Step 2 — Configure encryption
Section titled “Step 2 — Configure encryption”AES-256 provider (development / non-Vault environments)
Section titled “AES-256 provider (development / non-Vault environments)”Add the module dependency and configure the passphrase:
[DependsOn(typeof(GranitEncryptionModule))]public sealed class AppModule : GranitModule { }{ "Encryption": { "PassPhrase": "<derived-from-vault-or-secure-store>", "ProviderName": "Aes" }}Vault Transit provider (production)
Section titled “Vault Transit provider (production)”[DependsOn(typeof(GranitVaultHashiCorpModule))]public sealed class AppModule : GranitModule { }{ "Vault": { "Address": "https://vault.internal:8200", "AuthMethod": "Kubernetes", "KubernetesRole": "my-backend", "TransitMountPoint": "transit" }, "Encryption": { "ProviderName": "Vault" }}The ProviderName setting selects the active provider:
| Provider | ProviderName | Latency | When to use |
|---|---|---|---|
AesStringEncryptionProvider | "Aes" | < 1 ms | Settings, cache, high-frequency operations |
VaultStringEncryptionProvider | "Vault" | 10—20 ms | High-security operations, HashiCorp Vault |
AzureKeyVaultStringEncryptionProvider | "AzureKeyVault" | 10—30 ms | High-security operations, Azure environments |
Step 3 — Encrypt fields before persistence
Section titled “Step 3 — Encrypt fields before persistence”Inject IStringEncryptionService to encrypt and decrypt individual fields:
using Granit.Encryption;
namespace MyApp.Services;
public sealed class PatientService( AppDbContext db, IStringEncryptionService encryption){ public async Task<Guid> CreateAsync( string firstName, string lastName, string nirNumber, CancellationToken cancellationToken) { var patient = new Patient { FirstName = firstName, LastName = lastName, NirNumberEncrypted = encryption.Encrypt(nirNumber) };
db.Patients.Add(patient); await db.SaveChangesAsync(cancellationToken);
return patient.Id; }
public string? DecryptNir(string cipherText) => encryption.Decrypt(cipherText);}The AES-256-CBC provider generates a random IV for every encryption call
(RandomNumberGenerator.GetBytes(16)). The ciphertext format is
Base64(IV[16] || CipherText[N]) — the IV is extracted automatically during decryption.
Step 4 — Use Vault Transit for high-security operations
Section titled “Step 4 — Use Vault Transit for high-security operations”For data that requires centralized key management (encryption keys never leave Vault),
use ITransitEncryptionService:
using Granit.Vault;
namespace MyApp.Services;
public sealed class HealthRecordService(ITransitEncryptionService transit){ public async Task<string> EncryptDiagnosisAsync( string diagnosis, CancellationToken cancellationToken) { // Key name corresponds to a Transit key in Vault var encrypted = await transit.EncryptAsync( "health-records", diagnosis, cancellationToken); // Returns "vault:v1:..." -- version-tagged ciphertext return encrypted; }
public async Task<string> DecryptDiagnosisAsync( string cipherText, CancellationToken cancellationToken) => await transit.DecryptAsync( "health-records", cipherText, cancellationToken);}Step 5 — Encrypt cached values
Section titled “Step 5 — Encrypt cached values”For data stored in Redis or another distributed cache, enable encrypted serialization in FusionCache to provide transparent AES-256 encryption of cached values:
using ZiggyCreatures.Caching.Fusion;
namespace MyApp.Caching;
public sealed class PatientCacheItem{ public Guid Id { get; set; } public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty;}FusionCache with the encrypted serializer automatically encrypts before writing to Redis and decrypts after reading. Even if the cache backend is compromised, the data is unreadable.
public sealed class PatientCacheService( IFusionCache cache, AppDbContext db){ public async Task<PatientCacheItem?> GetAsync( Guid id, CancellationToken cancellationToken) { var cacheKey = $"patient:{id}";
return await cache.GetOrSetAsync( cacheKey, async (ctx, ct) => { var patient = await db.Patients.FindAsync([id], ct); if (patient is null) { return null; }
return new PatientCacheItem { Id = patient.Id, FirstName = patient.FirstName, LastName = patient.LastName }; }, token: cancellationToken); }}Step 6 — Encrypt settings automatically
Section titled “Step 6 — Encrypt settings automatically”Settings declared with IsEncrypted = true are encrypted at rest in the database.
The cache stores plaintext to avoid double encryption (Redis is already protected
by AesCacheValueEncryptor):
public sealed class AppSettingDefinitionProvider : ISettingDefinitionProvider{ public void Define(ISettingDefinitionContext context) { context.Add(new SettingDefinition("Integrations.ExternalApiKey") { IsEncrypted = true, IsVisibleToClients = false, Providers = { GlobalSettingValueProvider.ProviderName } }); }}The EfCoreSettingStore calls IStringEncryptionService.Encrypt() on write and
Decrypt() on read — application code always works with plaintext.
Encryption architecture summary
Section titled “Encryption architecture summary”| Layer | Mechanism | Scope | Key management |
|---|---|---|---|
| Field-level (AES) | IStringEncryptionService | Individual entity properties | Passphrase via PBKDF2 |
| Field-level (Vault Transit) | ITransitEncryptionService | Individual entity properties | Vault-managed, auto-rotation |
| Field-level (Azure Key Vault) | IStringEncryptionService | Individual entity properties | Azure Key Vault RSA-OAEP-256 |
| Cache | FusionCache encrypted serializer | Entire cached object | Local AES-256 key |
| Settings | IsEncrypted = true | Setting values in database | IStringEncryptionService |
Verify
Section titled “Verify”Confirm encryption is working by inspecting the raw database value:
# Create a patient with a NISS (sensitive field)curl -s -X POST http://localhost:5000/api/v1/patients \ -H "Content-Type: application/json" \ -d '{"name": "Jane Doe", "niss": "85073000123"}'
# Read via API — decrypted transparentlycurl -s http://localhost:5000/api/v1/patients/1 | jq .niss# → "85073000123"
# Read raw database value — should be ciphertextpsql -c "SELECT \"Niss\" FROM \"Patients\" LIMIT 1;" "$CONNECTION_STRING"# → "vault:v1:base64ciphertext..."Next steps
Section titled “Next steps”- Manage application settings — encrypted settings with cascading resolution
- Vault and Encryption reference — full API and configuration reference
- Observability reference — monitor encryption operations with OpenTelemetry