Skip to content

Encrypt sensitive data

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.Core
  • 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, use the [CacheEncrypted] attribute to enable transparent AES-256 encryption of cached values:

using Granit.Caching;
namespace MyApp.Caching;
[CacheEncrypted]
public sealed class PatientCacheItem
{
public Guid Id { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
}

The ICacheService<PatientCacheItem> 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(
ICacheService<PatientCacheItem> cache,
AppDbContext db)
{
public async Task<PatientCacheItem?> GetAsync(
Guid id,
CancellationToken cancellationToken)
{
var cacheKey = $"patient:{id}";
var cached = await cache.GetAsync(cacheKey, cancellationToken);
if (cached is not null)
{
return cached;
}
var patient = await db.Patients.FindAsync([id], cancellationToken);
if (patient is null)
{
return null;
}
var item = new PatientCacheItem
{
Id = patient.Id,
FirstName = patient.FirstName,
LastName = patient.LastName
};
await cache.SetAsync(cacheKey, item, cancellationToken);
return item;
}
}

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 }
});
}
}
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
Cache[CacheEncrypted]Entire cached objectLocal AES-256 key
SettingsIsEncrypted = trueSetting values in databaseIStringEncryptionService