Skip to main content

Field-Level Encryption

Encrypt specific fields in your entities using the [Encrypted] attribute.

Overview​

Field-level encryption lets you encrypt individual properties on your entities. Encrypted fields are stored as binary data in DynamoDB and decrypted transparently when entities are loaded.

Marking Fields for Encryption​

Use the [Encrypted] attribute on any property that should be encrypted at rest:

[DynamoDbTable("customers")]
public partial class Customer
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string CustomerId { get; set; } = string.Empty;

[DynamoDbAttribute("name")]
public string Name { get; set; } = string.Empty;

[Encrypted]
[Sensitive]
[DynamoDbAttribute("ssn")]
public string SocialSecurityNumber { get; set; } = string.Empty;

[Encrypted]
[Sensitive]
[DynamoDbAttribute("creditCard")]
public string CreditCardNumber { get; set; } = string.Empty;
}

Transparent Encryption and Decryption​

Once configured, encryption and decryption happen automatically during Put and Get operations:

// Data is encrypted before writing to DynamoDB
await table.Customers.PutAsync(customer);

// Data is decrypted after reading from DynamoDB
var loaded = await table.Customers.GetAsync("customer123");
Console.WriteLine(loaded.SocialSecurityNumber); // Decrypted value

Decryption Failure Modes​

By default, any decryption failure throws an exception. You can configure the library to skip encrypted fields that fail to decrypt, which is useful for STS downscoping scenarios where a service has reduced KMS permissions.

DecryptionFailureMode Enum​

ValueBehavior
Throw (default)Any decryption failure throws an exception
SkipFieldsRecoverable failures (no encryptor, access denied) leave the property at its CLR default and log a warning. Integrity failures (corrupted ciphertext, wrong key) always throw.

Configuration​

using Oproto.FluentDynamoDb;
using Oproto.FluentDynamoDb.Providers.Encryption;

// Skip encrypted fields when decryption fails (e.g., access denied)
var options = new FluentDynamoDbOptions()
.WithEncryption(encryptor)
.WithDecryptionFailureMode(DecryptionFailureMode.SkipFields);

var table = new CustomersTable(client, "customers", options);

Read-Only Access Without Encryptor​

When a service only needs non-encrypted fields:

// No encryptor configured — encrypted fields are skipped
var readOnlyOptions = new FluentDynamoDbOptions()
.WithDecryptionFailureMode(DecryptionFailureMode.SkipFields);

var table = new CustomersTable(client, "customers", readOnlyOptions);

// Non-encrypted fields load normally; encrypted fields stay at CLR default
var customer = await table.Customers.GetAsync("customer123");
Console.WriteLine(customer.Name); // Populated normally
Console.WriteLine(customer.SocialSecurityNumber); // "" (CLR default)

Write Behavior​

Write operations (ToDynamoDbAsync) are never affected by this setting — they always throw on failure to prevent silent data loss.

Failure Classification​

CategoryExamplesBehavior in SkipFields Mode
RecoverableNo encryptor, KMS access deniedField skipped, warning logged
IntegrityInvalid ciphertext, wrong key, context validation failedAlways throws

Combining with Other Attributes​

The [Encrypted] attribute works alongside other attributes:

CombinationBehavior
[Encrypted] + [Sensitive]Encrypted at rest, redacted in logs
[Encrypted] + [BlobStorage]Encrypted before uploading to S3
[Encrypted] + [JsonBlob]JSON serialized, then encrypted

Cache TTL​

Control how long decrypted data keys are cached:

[Encrypted(CacheTtlSeconds = 600)]
[DynamoDbAttribute("ssn")]
public string SocialSecurityNumber { get; set; } = string.Empty;

Per-Property Key Aliases​

By default, all encrypted properties on an entity use the same KMS key. With the KeyAlias property on [Encrypted], you can route different properties to different KMS keys based on data classification:

[DynamoDbTable("customers")]
public partial class Customer
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string CustomerId { get; set; } = string.Empty;

[DynamoDbAttribute("name")]
public string Name { get; set; } = string.Empty;

[Encrypted(KeyAlias = "pii")]
[Sensitive]
[DynamoDbAttribute("ssn")]
public string Ssn { get; set; } = string.Empty;

[Encrypted(KeyAlias = "financial")]
[Sensitive]
[DynamoDbAttribute("accountNumber")]
public string AccountNumber { get; set; } = string.Empty;
}

When KeyAlias is specified, the value is passed through the encryption pipeline to ResolveKeyIdAsync on your IKmsKeyResolver implementation. When omitted (null), the resolver falls through to context-based or default key resolution.

Configuring Alias-to-Key Mappings​

Use the aliasKeyMap parameter on DefaultKmsKeyResolver to map aliases to KMS key ARNs:

var resolver = new DefaultKmsKeyResolver(
defaultKeyId: "arn:aws:kms:us-east-1:123456789012:key/default-key",
contextKeyMap: new Dictionary<string, string>
{
["tenant-a"] = "arn:aws:kms:us-east-1:123456789012:key/tenant-a-key",
["tenant-b"] = "arn:aws:kms:us-east-1:123456789012:key/tenant-b-key"
},
aliasKeyMap: new Dictionary<string, string>
{
["pii"] = "arn:aws:kms:us-east-1:123456789012:key/pii-key",
["financial"] = "arn:aws:kms:us-east-1:123456789012:key/financial-key"
});

Resolution priority: aliasKeyMap → contextKeyMap → defaultKeyId. Both maps use case-sensitive lookups.

Async Key Resolution (Breaking Change)​

Breaking Change

The IKmsKeyResolver interface has been converted from synchronous to asynchronous in v1.1.0. All custom implementations must be updated.

Old Interface (v1.0.0)​

public interface IKmsKeyResolver
{
string ResolveKeyId(string? contextId);
}

New Interface (v1.1.0)​

public interface IKmsKeyResolver
{
Task<string> ResolveKeyIdAsync(
string? contextId,
string? keyAlias = null,
CancellationToken cancellationToken = default);
}

Benefits​

  • True async resolution — Key lookups can now call databases, secrets managers, or external APIs without blocking
  • Per-property key aliases — The keyAlias parameter carries the alias from [Encrypted(KeyAlias = "...")] to the resolver
  • Cooperative cancellation — CancellationToken support enables graceful shutdown and timeout handling

DefaultKmsKeyResolver Updates​

The DefaultKmsKeyResolver constructor now accepts an optional aliasKeyMap parameter:

var resolver = new DefaultKmsKeyResolver(
defaultKeyId: "arn:aws:kms:us-east-1:123456789012:key/default-key",
contextKeyMap: new Dictionary<string, string>
{
["tenant-a"] = "arn:aws:kms:us-east-1:123456789012:key/tenant-a-key",
["tenant-b"] = "arn:aws:kms:us-east-1:123456789012:key/tenant-b-key"
},
aliasKeyMap: new Dictionary<string, string>
{
["pii"] = "arn:aws:kms:us-east-1:123456789012:key/pii-key",
["financial"] = "arn:aws:kms:us-east-1:123456789012:key/financial-key"
});

The aliasKeyMap enables mapping key aliases declared on [Encrypted(KeyAlias = "...")] to specific KMS key ARNs. Resolution priority is: aliasKeyMap → contextKeyMap → defaultKeyId.

Migration Guide​

Breaking Change

All IKmsKeyResolver implementations must migrate from ResolveKeyId to ResolveKeyIdAsync.

Before (v1.0.0)​

public class MyTenantKeyResolver : IKmsKeyResolver
{
public string ResolveKeyId(string? contextId)
{
// Synchronous lookup
return _tenantKeyMap[contextId ?? "default"];
}
}

After (v1.1.0)​

public class MyTenantKeyResolver : IKmsKeyResolver
{
public async Task<string> ResolveKeyIdAsync(
string? contextId,
string? keyAlias = null,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();

// Can now use async operations (database lookups, vault calls, etc.)
if (keyAlias is not null && _aliasMap.TryGetValue(keyAlias, out var aliasKey))
return aliasKey;

return await _tenantKeyService.GetKeyAsync(
contextId ?? "default", cancellationToken);
}
}

Migration Steps​

  1. Rename ResolveKeyId → ResolveKeyIdAsync
  2. Change return type from string to Task<string>
  3. Add string? keyAlias = null and CancellationToken cancellationToken = default parameters
  4. Optionally handle the keyAlias parameter for per-property key routing
  5. Replace any blocking I/O with async equivalents
  6. Pass cancellationToken to downstream async calls

See Also​