| title | category | order | keywords | |||||||
|---|---|---|---|---|---|---|---|---|---|---|
Field-Level Security |
advanced-topics |
50 |
|
Documentation > Advanced Topics > Field-Level Security
Protect sensitive data in your DynamoDB entities through logging redaction and optional KMS-based encryption. This guide covers both mechanisms and how to use them together.
Oproto.FluentDynamoDb provides two complementary security features:
- Logging Redaction (Built-in) - Exclude sensitive field values from log output
- Field Encryption (Optional) - Encrypt fields at rest using AWS KMS
Both features use simple attributes and integrate seamlessly with the source generator.
- Logging Redaction
- Field Encryption
- Multi-Context Encryption
- Combined Security Features
- Integration with Blob Storage
- Best Practices
- Troubleshooting
The [Sensitive] attribute marks fields that should be excluded from logging output. This is useful for compliance with data protection regulations like GDPR, HIPAA, or PCI-DSS.
using Oproto.FluentDynamoDb.Attributes;
[DynamoDbTable("Users")]
public partial class User
{
[PartitionKey]
public string UserId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
[Sensitive] // Redacted from logs
public string Email { get; set; } = string.Empty;
[Sensitive] // Redacted from logs
public string PhoneNumber { get; set; } = string.Empty;
}When logging is enabled, sensitive field values are replaced with [REDACTED]:
// Log output:
// { UserId: "user-123", Name: "John Doe", Email: "[REDACTED]", PhoneNumber: "[REDACTED]" }The field name is preserved for debugging, but the value is hidden.
The [Sensitive] attribute affects:
- LINQ expression logging (query and filter expressions)
- String-based expression logging
- Query parameter logging
- Query results logging
- Put operation logging
- Update operation logging
- Error messages containing entity data
- All diagnostic output from
IDynamoDbLogger
When using LINQ expressions, sensitive property values are automatically redacted:
var email = "user@example.com";
var ssn = "123-45-6789";
await table.Query<User>()
.Where(x => x.PartitionKey == userId)
.WithFilter<User>(x => x.Email == email && x.SocialSecurityNumber == ssn)
.ToListAsync();
// Log output:
// Filter expression: email = :p0 AND ssn = :p1
// Parameters: { :p0 = [REDACTED], :p1 = [REDACTED] }No additional packages required - logging redaction is built into the core library.
dotnet add package Oproto.FluentDynamoDbThe [Encrypted] attribute marks fields for encryption at rest using AWS KMS. Encrypted data is stored in DynamoDB as binary (B) attribute type using the AWS Encryption SDK message format.
Field encryption requires the optional encryption package:
dotnet add package Oproto.FluentDynamoDb.Encryption.KmsThis package includes:
- AWS Encryption SDK integration
- KMS keyring support
- Data key caching
- Encryption context management
using Oproto.FluentDynamoDb.Attributes;
[DynamoDbTable("CustomerData")]
public partial class CustomerData
{
[PartitionKey]
public string CustomerId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
[Encrypted] // Encrypted at rest
[Sensitive] // Also redacted from logs
public string SocialSecurityNumber { get; set; } = string.Empty;
[Encrypted]
[Sensitive]
public string CreditCardNumber { get; set; } = string.Empty;
}using Oproto.FluentDynamoDb.Encryption.Kms;
// Load KMS key ARNs from secure configuration (NOT hardcoded!)
var keyResolver = new DefaultKmsKeyResolver(
defaultKeyId: configuration["Kms:DefaultKeyArn"],
contextKeyMap: new Dictionary<string, string>
{
["tenant-a"] = configuration["Kms:TenantA:KeyArn"],
["tenant-b"] = configuration["Kms:TenantB:KeyArn"]
});Security Note: Never hardcode KMS key ARNs in source code. Always load from secure configuration, environment variables, or a secrets manager.
var encryptorOptions = new AwsEncryptionSdkOptions
{
EnableCaching = true,
DefaultCacheTtlSeconds = 300, // 5 minutes
MaxMessagesPerDataKey = 100,
MaxBytesPerDataKey = 100 * 1024 * 1024 // 100 MB
};var encryptor = new AwsEncryptionSdkFieldEncryptor(keyResolver, encryptorOptions);using Oproto.FluentDynamoDb;
var options = new FluentDynamoDbOptions()
.WithEncryption(encryptor);
var table = new CustomerDataTable(dynamoClient, "customers", options);Here's a complete example showing all the steps together:
using Amazon.DynamoDBv2;
using Oproto.FluentDynamoDb;
using Oproto.FluentDynamoDb.Encryption.Kms;
// 1. Create DynamoDB client
var dynamoClient = new AmazonDynamoDBClient();
// 2. Configure key resolver (load from secure configuration!)
var keyResolver = new DefaultKmsKeyResolver(
defaultKeyId: configuration["Kms:DefaultKeyArn"]);
// 3. Configure encryption options
var encryptorOptions = new AwsEncryptionSdkOptions
{
EnableCaching = true,
DefaultCacheTtlSeconds = 300
};
// 4. Create encryptor
var encryptor = new AwsEncryptionSdkFieldEncryptor(keyResolver, encryptorOptions);
// 5. Configure FluentDynamoDbOptions with encryption
var options = new FluentDynamoDbOptions()
.WithEncryption(encryptor);
// 6. Create table with options
var table = new CustomerDataTable(dynamoClient, "customers", options);- Encryption: Before storing in DynamoDB, the source generator calls
IFieldEncryptor.EncryptAsync() - Storage: Encrypted data is stored as Binary (B) attribute type in AWS Encryption SDK format
- Decryption: When reading from DynamoDB, the source generator calls
IFieldEncryptor.DecryptAsync() - Transparency: Your application code works with plaintext - encryption/decryption is automatic
Encrypted fields use the AWS Encryption SDK message format, which includes:
- Algorithm suite identifier
- Encrypted data key(s)
- Initialization vector (IV)
- Encrypted content
- Authentication tag
- Digital signature (for key commitment)
This format is:
- Industry-standard and interoperable with other AWS services
- Includes built-in integrity checking
- Supports algorithm agility
- Prevents key substitution attacks (key commitment)
Multi-context encryption allows different encryption keys for different contexts (tenants, customers, regions, etc.). This is essential for:
- Multi-tenant applications
- Data residency requirements
- Customer-managed keys
- Regulatory compliance
The encryption context is passed at runtime, not hardcoded in attributes:
// Option 1: Per-operation context (recommended - most explicit)
await customerTable.PutItem(customerData)
.WithEncryptionContext("tenant-123")
.ExecuteAsync();
// Option 2: Ambient context (for middleware scenarios)
EncryptionContext.Current = "tenant-123";
await customerTable.PutItem(customerData).ExecuteAsync();For querying encrypted fields, you must manually encrypt the query parameters. This is necessary because automatic encryption would break non-equality operations like range queries and begins_with.
Use manual encryption for:
- ✅ Equality comparisons (
==) - ✅ IN queries
Do NOT use manual encryption for:
- ❌ Range queries (
>,<,>=,<=,BETWEEN) - ❌ String operations (
begins_with,contains) - ❌ Numeric operations
Why? Encrypted values are opaque ciphertext - they don't preserve ordering or string relationships.
Use table.Encrypt() directly in LINQ expressions:
[DynamoDbTable("Users")]
public partial class User
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string UserId { get; set; } = string.Empty;
[DynamoDbAttribute("ssn")]
[Encrypted]
[Sensitive]
public string SocialSecurityNumber { get; set; } = string.Empty;
}
// Set ambient encryption context (same pattern as Put/Get operations)
EncryptionContext.Current = "tenant-123";
// Encrypt value in LINQ expression
var ssn = "123-45-6789";
var users = await table.Query<User>()
.Where(x => x.UserId == userId)
.WithFilter<User>(x => x.SocialSecurityNumber == table.Encrypt(ssn, "SocialSecurityNumber"))
.ToListAsync();Use table.EncryptValue() to encrypt values before the query:
// Set ambient encryption context
EncryptionContext.Current = "tenant-123";
// Pre-encrypt the value
var ssn = "123-45-6789";
var encryptedSsn = table.EncryptValue(ssn, "SocialSecurityNumber");
// Use encrypted value in query
var users = await table.Query<User>()
.Where(x => x.UserId == userId)
.WithFilter<User>(x => x.SocialSecurityNumber == encryptedSsn)
.ToListAsync();Manual encryption also works with string-based expressions:
// With format strings
EncryptionContext.Current = "tenant-123";
await table.Query()
.Where("pk = {0}", userId)
.WithFilter("ssn = {0}", table.Encrypt(ssn, "SocialSecurityNumber"))
.ExecuteAsync();
// With named parameters
EncryptionContext.Current = "tenant-123";
await table.Query()
.Where("pk = :pk")
.WithValue(":pk", userId)
.WithFilter("ssn = :ssn")
.WithValue(":ssn", table.Encrypt(ssn, "SocialSecurityNumber"))
.ExecuteAsync();Manual encryption uses the same ambient EncryptionContext.Current pattern as Put/Get operations:
// Set context before encryption
EncryptionContext.Current = "tenant-123";
// All encryption operations in this async flow use the context
var encryptedValue = table.Encrypt(value, fieldName);
await table.PutItem(entity).ExecuteAsync();
await table.Query<User>()
.WithFilter<User>(x => x.EncryptedField == table.Encrypt(value, "EncryptedField"))
.ToListAsync();
// Context automatically cleared when async flow completesIf encryption is not configured, a clear error is thrown:
try
{
var encrypted = table.Encrypt(value, "FieldName");
}
catch (InvalidOperationException ex)
{
// "Cannot encrypt value: IFieldEncryptor not configured.
// Call options.WithEncryption(encryptor) when creating your table."
}Solution: Configure encryption using FluentDynamoDbOptions:
var encryptor = new AwsEncryptionSdkFieldEncryptor(keyResolver);
var options = new FluentDynamoDbOptions()
.WithEncryption(encryptor);
var table = new SecretsTable(client, "secrets", options);- Manual encryption is explicit - you control when encryption happens
- Use ambient
EncryptionContext.Currentfor context (same as Put/Get) - Only use for equality comparisons
- Encrypted values cannot be used in range queries or string operations
- Combine with
[Sensitive]to redact encrypted values from logs - The
Encrypt()andEncryptValue()methods are equivalent (EncryptValue is an alias for clarity)
The context string (e.g., "tenant-123") is passed to IKmsKeyResolver, which returns the appropriate KMS key ARN:
public interface IKmsKeyResolver
{
string ResolveKeyId(string? contextId);
}The DefaultKmsKeyResolver uses a dictionary lookup with fallback:
var keyResolver = new DefaultKmsKeyResolver(
defaultKeyId: "arn:aws:kms:us-east-1:123456789012:key/default-key-id",
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",
["tenant-c"] = "arn:aws:kms:us-east-1:123456789012:key/tenant-c-key"
});
// Usage:
// WithEncryptionContext("tenant-a") → uses tenant-a-key
// WithEncryptionContext("tenant-b") → uses tenant-b-key
// WithEncryptionContext("unknown") → uses default-key-id
// No context provided → uses default-key-idFor dynamic key resolution (database, external service, etc.):
public class DatabaseKmsKeyResolver : IKmsKeyResolver
{
private readonly IKeyRepository _keyRepo;
private readonly string _defaultKey;
public DatabaseKmsKeyResolver(IKeyRepository keyRepo, string defaultKey)
{
_keyRepo = keyRepo;
_defaultKey = defaultKey;
}
public string ResolveKeyId(string? contextId)
{
if (contextId == null)
return _defaultKey;
// Load from database, cache, external service, etc.
var keyArn = _keyRepo.GetKmsKeyForTenant(contextId);
return keyArn ?? _defaultKey;
}
}For middleware scenarios, use the ambient context:
// In middleware or request handler
// AsyncLocal ensures thread-safety and prevents cross-request leakage
EncryptionContext.Current = httpContext.GetTenantId();
// All operations in this async flow use the context
await customerTable.PutItem(data).ExecuteAsync();
await customerTable.GetItem("key").ExecuteAsync();
// Context automatically cleared when request completesThread Safety: EncryptionContext.Current uses AsyncLocal<string?>, which:
- Flows through async/await calls
- Does NOT leak across threads or requests
- Is isolated per async execution context
The context identifier is included in the AWS Encryption SDK encryption context:
{
"field": "SensitiveData",
"context": "tenant-123", // Your context ID
"entity": "CustomerData"
}This provides:
- Audit trail in CloudTrail logs
- Additional authenticated data (AAD)
- Protection against ciphertext substitution
Combine [Sensitive] and [Encrypted] for maximum protection:
[DynamoDbTable("SecureEntities")]
public partial class SecureEntity
{
[PartitionKey]
public string EntityId { get; set; } = string.Empty;
// Encrypted at rest AND redacted from logs
[Encrypted]
[Sensitive]
public string HighlyConfidentialData { get; set; } = string.Empty;
// Only redacted from logs (not encrypted)
[Sensitive]
public string Email { get; set; } = string.Empty;
// Only encrypted (still appears in logs)
[Encrypted]
public string EncryptedButLogged { get; set; } = string.Empty;
// Neither encrypted nor redacted
public string PublicData { get; set; } = string.Empty;
}| Scenario | Use [Sensitive] |
Use [Encrypted] |
|---|---|---|
| PII in logs | ✅ | Optional |
| Data at rest protection | Optional | ✅ |
| Compliance (GDPR, HIPAA) | ✅ | ✅ |
| Performance-critical | ✅ | |
| Multi-tenant isolation | Optional | ✅ |
| Audit trail needed | Optional | ✅ |
For large encrypted fields that might exceed DynamoDB's 400KB item size limit, combine encryption with external blob storage.
using Oproto.FluentDynamoDb.Attributes;
[DynamoDbTable("Documents")]
public partial class Document
{
[PartitionKey]
public string DocumentId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
// Encrypted AND stored in S3
[Encrypted]
[BlobReference(BlobProvider.S3, BucketName = "my-encrypted-blobs", KeyPrefix = "documents/")]
[Sensitive]
public byte[] LargeEncryptedContent { get; set; } = Array.Empty<byte>();
}- Encryption First: Data is encrypted using AWS Encryption SDK
- Blob Storage: Encrypted data is stored in S3 via
IBlobStorageProvider - DynamoDB Reference: DynamoDB stores the S3 URI (e.g.,
s3://bucket/key) - Transparent Retrieval: On read, the library fetches from S3 and decrypts automatically
Configure automatic external storage for large encrypted fields:
var options = new AwsEncryptionSdkOptions
{
DefaultKeyId = configuration["Kms:DefaultKeyArn"],
AutoExternalBlobThreshold = 350 * 1024, // 350KB
ExternalBlobBucket = "my-encrypted-blobs",
ExternalBlobKeyPrefix = "auto/"
};When encrypted data exceeds the threshold, it's automatically stored externally even without [BlobReference].
When combining encryption with blob storage, configure both in FluentDynamoDbOptions:
using Amazon.S3;
using Oproto.FluentDynamoDb;
using Oproto.FluentDynamoDb.BlobStorage.S3;
using Oproto.FluentDynamoDb.Encryption.Kms;
// Create S3 blob provider
var s3Client = new AmazonS3Client();
var blobProvider = new S3BlobProvider(
s3Client,
bucketName: "my-encrypted-blobs",
keyPrefix: "documents/");
// Create encryptor
var keyResolver = new DefaultKmsKeyResolver(configuration["Kms:DefaultKeyArn"]);
var encryptor = new AwsEncryptionSdkFieldEncryptor(keyResolver);
// Configure both blob storage and encryption
var options = new FluentDynamoDbOptions()
.WithBlobStorage(blobProvider)
.WithEncryption(encryptor);
var table = new DocumentsTable(dynamoClient, "documents", options);The central configuration object for FluentDynamoDb. Use WithEncryption() to enable field-level encryption:
var options = new FluentDynamoDbOptions()
.WithEncryption(encryptor);See the Configuration Guide for complete details on FluentDynamoDbOptions.
Configuration options for the AWS Encryption SDK field encryptor:
public class AwsEncryptionSdkOptions
{
/// <summary>
/// Enable data key caching (default: true).
/// Uses AWS Encryption SDK's CachingCryptoMaterialsManager.
/// </summary>
public bool EnableCaching { get; set; } = true;
/// <summary>
/// Default cache TTL for data keys (seconds).
/// Can be overridden per-field via EncryptedAttribute.
/// </summary>
public int DefaultCacheTtlSeconds { get; set; } = 300;
/// <summary>
/// Maximum number of messages encrypted with a single data key.
/// AWS Encryption SDK best practice: limit reuse.
/// </summary>
public int MaxMessagesPerDataKey { get; set; } = 100;
/// <summary>
/// Maximum bytes encrypted with a single data key.
/// </summary>
public long MaxBytesPerDataKey { get; set; } = 100 * 1024 * 1024; // 100 MB
/// <summary>
/// Algorithm suite to use (default: AES_256_GCM_HKDF_SHA512_COMMIT_KEY_ECDSA_P384).
/// AWS Encryption SDK 3.x uses key commitment by default.
/// </summary>
public string Algorithm { get; set; } =
"AES_256_GCM_HKDF_SHA512_COMMIT_KEY_ECDSA_P384";
}Resolves KMS key ARNs based on context identifiers:
// Single key for all contexts
var keyResolver = new DefaultKmsKeyResolver(
defaultKeyId: "arn:aws:kms:us-east-1:123456789012:key/my-key");
// Multi-tenant with per-tenant keys
var keyResolver = 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"
});Per-field encryption configuration:
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class EncryptedAttribute : Attribute
{
/// <summary>
/// Cache TTL for data keys (seconds) for this specific field.
/// Overrides AwsEncryptionSdkOptions.DefaultCacheTtlSeconds.
/// Default: 300 (5 minutes)
/// </summary>
public int CacheTtlSeconds { get; set; } = 300;
}Example:
[Encrypted(CacheTtlSeconds = 600)] // 10 minutes for this field
public string HighFrequencyField { get; set; } = string.Empty;
[Encrypted(CacheTtlSeconds = 60)] // 1 minute for this field
public string LowFrequencyField { get; set; } = string.Empty;- Never Hardcode Keys: Load KMS key ARNs from secure configuration, not source code
- Use IAM Policies: Restrict KMS key access using IAM policies and key policies
- Enable CloudTrail: Monitor KMS API calls for audit trails
- Rotate Keys: Use KMS automatic key rotation
- Combine Attributes: Use both
[Encrypted]and[Sensitive]for maximum protection
- Enable Caching: Keep
EnableCaching = trueto minimize KMS API calls - Tune Cache TTL: Balance security and performance based on your requirements
- Selective Encryption: Only encrypt truly sensitive fields
- Monitor Costs: KMS API calls have costs - use caching effectively
- Consider Field Size: Large encrypted fields increase latency
- Per-Tenant Keys: Use separate KMS keys per tenant for isolation
- Ambient Context: Use
EncryptionContext.Currentin middleware for automatic context flow - Validate Context: Ensure context is set before operations
- Test Isolation: Verify data encrypted with one tenant's key cannot be decrypted with another's
- Always Use [Sensitive]: Mark encrypted fields as sensitive to prevent accidental logging
- Structured Logging: Use structured logging to filter sensitive data
- Production Logging: Consider disabling detailed logging in production
- Configure Both: When using encryption with logging, configure both in
FluentDynamoDbOptions:
var options = new FluentDynamoDbOptions()
.WithLogger(logger.ToDynamoDbLogger())
.WithEncryption(encryptor);All encryption errors throw FieldEncryptionException:
try
{
await table.PutItem(data)
.WithEncryptionContext("tenant-123")
.ExecuteAsync();
}
catch (FieldEncryptionException ex)
{
Console.WriteLine($"Field: {ex.FieldName}");
Console.WriteLine($"Context: {ex.ContextId}");
Console.WriteLine($"Key: {ex.KeyId}");
Console.WriteLine($"Error: {ex.Message}");
Console.WriteLine($"Inner: {ex.InnerException?.Message}");
}FieldEncryptionException: Failed to encrypt field 'SensitiveData' - KMS access denied
FieldName: SensitiveData
ContextId: tenant-123
KeyId: arn:aws:kms:us-east-1:123456789012:key/abc-123
Solution: Check IAM permissions for kms:GenerateDataKey and kms:Decrypt
FieldEncryptionException: Failed to generate data key for field 'SensitiveData'
FieldName: SensitiveData
ContextId: tenant-123
KeyId: arn:aws:kms:us-east-1:123456789012:key/abc-123
Solution: Verify KMS key exists and is enabled
FieldEncryptionException: Failed to decrypt field 'SensitiveData' - data corruption or wrong key
FieldName: SensitiveData
ContextId: tenant-123
Solution: Verify correct KMS key is being used, check for data corruption
If you use [Encrypted] without the Encryption.Kms package, the source generator emits a warning:
Warning FDDB4001: Property 'SensitiveData' has [Encrypted] attribute but Oproto.FluentDynamoDb.Encryption.Kms package is not referenced
Solution: Add the package reference:
dotnet add package Oproto.FluentDynamoDb.Encryption.KmsProblem: Sensitive fields appear in logs
Solutions:
- Verify
[Sensitive]attribute is applied - Rebuild project to regenerate source code
- Check logging is enabled
- Verify
FluentDynamoDbOptionswithWithLogger()is passed to table constructor
Example fix:
using Oproto.FluentDynamoDb.Logging.Extensions;
var options = new FluentDynamoDbOptions()
.WithLogger(loggerFactory.ToDynamoDbLogger<UsersTable>());
var table = new UsersTable(client, "users", options);Problem: Data stored as plaintext
Solutions:
- Verify
FluentDynamoDbOptionswithWithEncryption()is passed to table constructor - Check
[Encrypted]attribute is applied to the property - Rebuild project to regenerate source code
- Verify KMS key ARN is valid
Example fix:
// Before (encryption not configured)
var table = new SecretsTable(client, "secrets");
// After (encryption configured)
var encryptor = new AwsEncryptionSdkFieldEncryptor(keyResolver);
var options = new FluentDynamoDbOptions()
.WithEncryption(encryptor);
var table = new SecretsTable(client, "secrets", options);Problem: Wrong encryption key used
Solutions:
- Verify
WithEncryptionContext()is called - Check
EncryptionContext.Currentis set - Verify
IKmsKeyResolveris configured correctly - Test key resolution logic
Problem: High latency or KMS costs
Solutions:
- Enable caching:
EnableCaching = true - Increase cache TTL:
DefaultCacheTtlSeconds = 600 - Reduce encrypted field count
- Monitor KMS API calls in CloudWatch
- Attribute Reference - Complete attribute documentation
- Logging Configuration - Configure logging and diagnostics
- Advanced Types - Blob storage integration
- Error Handling - Exception handling patterns
- Encryption.Kms Package README - Detailed encryption package documentation