e full-table re-encryption during key rotation. The trade-off is disciplined IAM scoping and deterministic encryption design for indexed fields.
Core Solution
Implementing production-grade encryption at rest requires a layered architecture that separates data encryption from key management. The recommended pattern uses envelope encryption with a cloud KMS, combined with selective application-level encryption for high-sensitivity fields.
Architecture Decisions
- Master Key vs Data Key Separation: The KMS holds a customer-managed key (CMK) that never leaves the service boundary. Application code requests a data key, which is returned encrypted and in plaintext. The plaintext data key encrypts the payload locally. The encrypted data key is stored alongside the ciphertext.
- Envelope Encryption: Minimizes KMS API calls. Data keys are cached in memory with short TTLs. Rotation triggers generation of new data keys without re-encrypting historical data.
- Deterministic vs Randomized Encryption: Use AES-GCM with randomized IVs for non-indexed fields. Use AES-SIV or HMAC-based deterministic encryption for fields requiring exact-match queries.
- Scope Definition: TDE or volume encryption covers the storage layer. Application encryption covers PII, credentials, and regulated data. Logs, backups, and snapshots must inherit the same cryptographic boundary via IAM policies and explicit encryption flags.
Step-by-Step Implementation
Step 1: Provision KMS Key with Automatic Rotation
Create a customer-managed key with 365-day rotation. Enable key policy restrictions to limit usage to specific IAM roles.
Step 2: Generate and Cache Data Keys
Request a data key from KMS. Store the encrypted data key version and plaintext data key in a secure in-memory cache with a 15-minute TTL.
Step 3: Encrypt Payload Locally
Use the plaintext data key with AES-256-GCM. Generate a random 12-byte nonce. Append the encrypted data key, nonce, and ciphertext for storage.
Step 4: Decrypt on Read
Retrieve the encrypted data key from storage. Call KMS to decrypt it. Use the plaintext data key and nonce to decrypt the ciphertext locally.
Step 5: Handle Key Rotation Gracefully
Tag ciphertext with the data key version. On decrypt failure, attempt fallback to previous key version. Trigger data key regeneration on version mismatch.
TypeScript Implementation
import { KMSClient, GenerateDataKeyCommand, DecryptCommand } from "@aws-sdk/client-kms";
import { createCipheriv, createDecipheriv, randomBytes, createHmac } from "crypto";
const kms = new KMSClient({ region: "us-east-1" });
const ALGORITHM = "aes-256-gcm";
const KEY_VERSION = "v1";
interface EncryptedPayload {
ciphertext: string;
encryptedDataKey: string;
nonce: string;
keyVersion: string;
authTag: string;
}
export async function encryptAtRest(plaintext: string): Promise<EncryptedPayload> {
// 1. Request data key from KMS
const dataKeyCmd = new GenerateDataKeyCommand({
KeyId: "alias/my-db-encryption-key",
KeySpec: "AES_256",
});
const dataKeyResp = await kms.send(dataKeyCmd);
const plaintextDataKey = dataKeyResp.Plaintext as Uint8Array;
const encryptedDataKey = Buffer.from(dataKeyResp.CiphertextBlob as Uint8Array).toString("base64");
// 2. Generate nonce and encrypt locally
const nonce = randomBytes(12);
const cipher = createCipheriv(ALGORITHM, plaintextDataKey, nonce);
let ciphertext = cipher.update(plaintext, "utf8", "base64");
ciphertext += cipher.final("base64");
const authTag = cipher.getAuthTag();
// 3. Clear plaintext key from memory
plaintextDataKey.fill(0);
return {
ciphertext,
encryptedDataKey,
nonce: Buffer.from(nonce).toString("hex"),
keyVersion: KEY_VERSION,
authTag: Buffer.from(authTag).toString("hex"),
};
}
export async function decryptAtRest(payload: EncryptedPayload): Promise<string> {
// 1. Decrypt data key via KMS
const decryptCmd = new DecryptCommand({
CiphertextBlob: Buffer.from(payload.encryptedDataKey, "base64"),
});
const decryptResp = await kms.send(decryptCmd);
const plaintextDataKey = decryptResp.Plaintext as Uint8Array;
try {
// 2. Decrypt payload locally
const nonce = Buffer.from(payload.nonce, "hex");
const authTag = Buffer.from(payload.authTag, "hex");
const decipher = createDecipheriv(ALGORITHM, plaintextDataKey, nonce);
decipher.setAuthTag(authTag);
let plaintext = decipher.update(payload.ciphertext, "base64", "utf8");
plaintext += decipher.final("utf8");
return plaintext;
} finally {
// 3. Clear plaintext key from memory
plaintextDataKey.fill(0);
}
}
// Deterministic encryption for indexed fields
export function encryptDeterministic(plaintext: string, dataKey: Uint8Array): string {
const hmac = createHmac("sha256", dataKey);
hmac.update(plaintext);
return hmac.digest("base64");
}
Architecture Rationale
- Memory Clearing:
plaintextDataKey.fill(0) prevents key material from lingering in V8 heap memory.
- Version Tagging:
keyVersion enables seamless rotation without full-table re-encryption.
- Deterministic Path:
encryptDeterministic uses HMAC over the plaintext with the data key, producing consistent outputs for exact-match queries while avoiding IV reuse vulnerabilities.
- KMS Boundary: Master keys never touch application memory. Audit trails are centralized. IAM policies control access.
Pitfall Guide
-
Assuming TDE Covers All Data Surfaces
Transparent Data Encryption only protects the primary storage volume. Database logs, temporary tables, read replicas, backups, and snapshots often bypass TDE unless explicitly configured. Always verify encryption inheritance across storage classes and backup pipelines.
-
Storing Keys in Environment Variables or Config Files
Static keys in .env, Kubernetes secrets, or application configs are the fastest path to credential leakage. Use a dedicated KMS with IAM-scoped access. Never embed key material in source control or container images.
-
Neglecting Key Rotation Policies
Keys that never rotate become single points of failure. Enable automatic rotation (typically 365 days). Implement versioned ciphertext storage and fallback decryption logic. Test rotation in staging before production rollout.
-
Over-Encrypting or Under-Encrypting
Encrypting entire tables degrades query performance and complicates indexing. Encrypting only high-sensitivity fields reduces blast radius. Define a data classification matrix: PII, credentials, and financial data require application-level encryption; operational data can rely on volume encryption.
-
Overly Permissive KMS IAM Policies
Granting kms:Decrypt to broad roles violates least privilege. Scope policies to specific key ARNs, required IAM roles, and VPC endpoints. Enable KMS key policies that restrict cross-account usage and require multi-factor authentication for administrative actions.
-
Ignoring Query and Index Implications
Randomized encryption breaks B-tree indexes. Exact-match queries require deterministic encryption or encrypted search patterns (e.g., blind indexing). Range queries on encrypted data require order-preserving encryption or application-layer filtering, both with known security trade-offs.
-
Skipping Restore and Decrypt Testing
Encryption is useless if decryption fails during incident response. Regularly test key recovery, backup decryption, and cross-region restore workflows. Document key version mapping and maintain offline key escrow for disaster recovery.
Best Practices from Production
- Use envelope encryption for all production workloads.
- Cache data keys in memory with short TTLs; never persist plaintext keys.
- Implement deterministic encryption only for fields requiring exact-match queries.
- Enable KMS CloudTrail/audit logging and set up alerts for unauthorized decrypt attempts.
- Run quarterly decryption drills against production backups.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Multi-tenant SaaS with strict data isolation | Application-level envelope encryption per tenant | Prevents cross-tenant data leakage; enables tenant-specific key rotation | Medium (KMS API calls + app compute) |
| Regulated healthcare (HIPAA/PHI) | KMS volume encryption + deterministic PII encryption | Meets compliance audit requirements; preserves query capability for clinical workflows | Low-Medium (infrastructure encryption + selective app layer) |
| High-throughput analytics warehouse | TDE + column-level encryption for sensitive dimensions | Minimizes query latency; balances compliance with performance | Low (storage controller overhead only) |
| Legacy database migration to cloud | Phased: TDE first, then application encryption for PII | Reduces migration risk; allows incremental security hardening | Medium (temporary dual-encryption overhead during transition) |
Configuration Template
# Terraform: KMS Key + RDS Encryption
resource "aws_kms_key" "db_encryption" {
description = "Customer-managed key for database encryption at rest"
deletion_window_in_days = 30
enable_key_rotation = true
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowRootAccount"
Effect = "Allow"
Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" }
Action = "kms:*"
Resource = "*"
},
{
Sid = "AllowAppRole"
Effect = "Allow"
Principal = { AWS = aws_iam_role.app_role.arn }
Action = ["kms:GenerateDataKey", "kms:Decrypt"]
Resource = "*"
}
]
})
}
resource "aws_kms_alias" "db_encryption_alias" {
name = "alias/my-db-encryption-key"
target_key_id = aws_kms_key.db_encryption.key_id
}
resource "aws_db_instance" "encrypted" {
engine = "postgres"
engine_version = "15.4"
instance_class = "db.r6g.large"
storage_encrypted = true
kms_key_id = aws_kms_key.db_encryption.arn
backup_retention_period = 7
copy_tags_to_snapshot = true
}
// Application encryption config
{
"encryption": {
"provider": "aws-kms",
"keyAlias": "alias/my-db-encryption-key",
"algorithm": "aes-256-gcm",
"cacheTtlMinutes": 15,
"deterministicFields": ["email", "account_id"],
"versionTag": "v1",
"memoryClear": true
}
}
Quick Start Guide
- Create a customer-managed KMS key with automatic rotation enabled. Note the key ARN or alias.
- Enable storage encryption on your database instance using the KMS key. Verify backups and snapshots inherit encryption.
- Add the TypeScript envelope encryption module to your data access layer. Replace direct string storage with
encryptAtRest() and decryptAtRest() calls.
- Configure deterministic encryption for fields requiring exact-match queries. Update database indexes to use the deterministic output.
- Run a decrypt validation test against a production backup. Confirm key version mapping and memory clearing behavior.