Skip to content

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.

  • A .NET 10 project with Granit
  • Granit.Encryption for AES-256 field-level encryption
  • Granit.Vault for vault abstractions (ITransitEncryptionService, IDatabaseCredentialProvider)
  • Granit.Vault.HashiCorp for HashiCorp Vault Transit encryption (production)
  • Granit.Vault.Azure for Azure Key Vault encryption (production, Azure environments)
  • A running HashiCorp Vault or Azure Key Vault instance (production only)
Terminal window
dotnet add package Granit.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"
}
}
[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:

ProviderProviderNameLatencyWhen to use
AesStringEncryptionProvider"Aes"< 1 msSettings, cache, high-frequency operations
VaultStringEncryptionProvider"Vault"10—20 msHigh-security operations, HashiCorp Vault
AzureKeyVaultStringEncryptionProvider"AzureKeyVault"10—30 msHigh-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);
}

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

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.

LayerMechanismScopeKey management
Field-level (AES)IStringEncryptionServiceIndividual entity propertiesPassphrase via PBKDF2
Field-level (Vault Transit)ITransitEncryptionServiceIndividual entity propertiesVault-managed, auto-rotation
Field-level (Azure Key Vault)IStringEncryptionServiceIndividual entity propertiesAzure Key Vault RSA-OAEP-256
CacheFusionCache encrypted serializerEntire cached objectLocal AES-256 key
SettingsIsEncrypted = trueSetting values in databaseIStringEncryptionService

Confirm encryption is working by inspecting the raw database value:

Terminal window
# 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 transparently
curl -s http://localhost:5000/api/v1/patients/1 | jq .niss
# → "85073000123"
# Read raw database value — should be ciphertext
psql -c "SELECT \"Niss\" FROM \"Patients\" LIMIT 1;" "$CONNECTION_STRING"
# → "vault:v1:base64ciphertext..."