Guide · AWS KMS · Envelope Encryption
AWS KMS Envelope Encryption for MCP Servers — GenerateDataKey, Context, Caching
The Encrypt API in AWS KMS has a hard 4,096-byte (4KB) limit on plaintext — you cannot use it to directly encrypt a database row, a config file, or any realistic payload; the correct pattern is envelope encryption, where you call GenerateDataKey to get a short-lived AES-256 data key, use that data key locally to encrypt the actual payload, store the KMS-encrypted data key (the "envelope") alongside the ciphertext, and never store the plaintext data key. This pattern shifts the load from KMS to local CPU: your application makes one GenerateDataKey call per logical write operation and one Decrypt call per read, rather than routing every byte through KMS. For MCP servers that log tool calls, cache user sessions, or encrypt tenant configuration, the difference between naive Encrypt and envelope encryption can be the difference between $0.30/day and $300/day in KMS charges.
TL;DR
Call GenerateDataKey to get a 256-bit data key, encrypt your payload locally with AES-256-GCM using the plaintext data key, store the ciphertext blob alongside the encrypted payload, discard the plaintext data key from memory. To decrypt, call Decrypt on the stored ciphertext blob to recover the data key, then decrypt the payload locally. See the KMS overview for key creation, the key policies guide for access control, the CloudTrail monitoring guide for auditing data key usage, and the multi-region guide for cross-region decrypt.
GenerateDataKey pattern
GenerateDataKey makes a single KMS API call and returns two objects: Plaintext (the raw 256-bit AES key, base64-encoded) and CiphertextBlob (the same key encrypted under your CMK). Use the plaintext key to encrypt data, store the ciphertext blob with the encrypted data, then wipe the plaintext key from memory. To decrypt later, call Decrypt on the stored ciphertext blob.
// Node.js — envelope encryption pattern for MCP server config storage
import { KMSClient, GenerateDataKeyCommand, DecryptCommand } from '@aws-sdk/client-kms';
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
const kms = new KMSClient({ region: 'us-east-1' });
const KEY_ID = 'alias/mcp-server-secrets-prod';
async function encryptPayload(plaintext: string, context: Record<string, string>): Promise<{
encryptedData: Buffer;
encryptedKey: Buffer;
iv: Buffer;
}> {
// 1. Ask KMS for a data key; one KMS API call regardless of payload size
const { Plaintext, CiphertextBlob } = await kms.send(new GenerateDataKeyCommand({
KeyId: KEY_ID,
KeySpec: 'AES_256',
EncryptionContext: context, // stored in CloudTrail, must match on Decrypt
}));
// 2. Encrypt payload locally with AES-256-GCM (no 4KB limit)
const iv = randomBytes(12); // 96-bit nonce for GCM
const cipher = createCipheriv('aes-256-gcm', Plaintext!, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag(); // 16-byte GCM auth tag
// 3. CRITICAL: wipe the plaintext data key from memory immediately
Plaintext!.fill(0);
// 4. Store: iv (12) + authTag (16) + encryptedData + encryptedKey blob
return {
encryptedData: Buffer.concat([iv, authTag, encrypted]),
encryptedKey: Buffer.from(CiphertextBlob!),
iv,
};
}
async function decryptPayload(
encryptedData: Buffer,
encryptedKey: Buffer,
context: Record<string, string>
): Promise<string> {
// 1. Recover the data key — one KMS Decrypt call; KMS knows which CMK from the ciphertext blob
const { Plaintext } = await kms.send(new DecryptCommand({
CiphertextBlob: encryptedKey,
EncryptionContext: context, // MUST match the context used in GenerateDataKey
}));
// 2. Extract iv + authTag + ciphertext from stored blob
const iv = encryptedData.subarray(0, 12);
const authTag = encryptedData.subarray(12, 28);
const ciphertext = encryptedData.subarray(28);
// 3. Decrypt locally
const decipher = createDecipheriv('aes-256-gcm', Plaintext!, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
Plaintext!.fill(0); // wipe data key again
return decrypted.toString('utf8');
}
The Decrypt call does not need a KeyId parameter — the CMK used to wrap the data key is embedded in the CiphertextBlob itself. KMS automatically uses the correct key. If the CMK has been deleted, Decrypt throws InvalidCiphertextException — the data is unrecoverable. This is why you must never schedule key deletion without first inventorying all ciphertext that depends on it.
Encryption context
Encryption context is a set of key-value string pairs that you pass to GenerateDataKey, Encrypt, and Decrypt. It is not encrypted — it is authenticated additional data (AAD) in the AES-GCM operation. The exact same context must be passed to Decrypt or the decryption fails. KMS also includes the context in every CloudTrail log entry, making it the primary audit mechanism for distinguishing which application, tenant, or resource generated or accessed a data key.
// Recommended encryption context structure for MCP servers:
const context = {
service: 'mcp-server', // which service owns this record
environment: 'production', // env isolation
tenantId: 'tenant-abc123', // tenant-specific decryption audit trail
dataClass: 'api-credentials', // what type of data (never put actual secrets here)
};
// If you pass context to GenerateDataKey but omit it on Decrypt:
// → KMSInvalidSignatureException: "The encryption context in the request
// does not match the encryption context used during data key generation"
// Partial context match is NOT allowed — all context keys from GenerateDataKey
// must be present with the same values on Decrypt; extra keys are allowed.
// Context best practices for MCP server tool call logging:
const toolCallContext = {
service: 'mcp-tool-call-logger',
serverId: mcpServerId, // ties every decrypt to a specific MCP server
sessionId: sessionId, // ties decrypt to a specific user session
};
// CloudTrail query later: "who decrypted records for tenant X in the last 24h?"
For multi-tenant MCP servers, including the tenant ID in encryption context provides a hard cryptographic binding between data and tenant: a Decrypt call that passes the wrong tenant ID will fail even if the caller has IAM permission to use the key. Combined with a key policy condition like kms:EncryptionContextKeys, you can require that all callers must pass a tenantId context key — a missing context value is a decryption failure, not just an audit gap.
Data key caching to reduce API costs
Calling GenerateDataKey once per database row or once per HTTP request turns KMS from a background service into a per-request bottleneck — both in latency (~1–3ms per call) and cost ($0.03 per 10,000 calls; 100k rows/day = $0.30/day; 10M rows/day = $30/day). Data key caching reuses a plaintext data key for multiple encrypt operations before discarding it, bounded by a maximum age (default 300 seconds) or maximum number of messages (default 1,000).
// Simple data key cache implementation for MCP servers
// Trade-off: if a process is compromised, the cached plaintext key is exposed for up to maxAge seconds
// Mitigation: set maxAge low (60s) for high-security contexts; use memory-only Map (no disk persistence)
interface CachedDataKey {
plaintextKey: Buffer;
encryptedKey: Buffer;
createdAt: number;
useCount: number;
}
class DataKeyCache {
private cache = new Map<string, CachedDataKey>();
private readonly maxAgeMs: number;
private readonly maxUses: number;
constructor(maxAgeSeconds = 300, maxUses = 1000) {
this.maxAgeMs = maxAgeSeconds * 1000;
this.maxUses = maxUses;
}
get(keyId: string): CachedDataKey | null {
const entry = this.cache.get(keyId);
if (!entry) return null;
const expired = Date.now() - entry.createdAt > this.maxAgeMs;
const overused = entry.useCount >= this.maxUses;
if (expired || overused) {
entry.plaintextKey.fill(0); // wipe before eviction
this.cache.delete(keyId);
return null;
}
entry.useCount++;
return entry;
}
set(keyId: string, plaintextKey: Buffer, encryptedKey: Buffer): void {
// Evict any existing entry for this keyId before setting new one
const existing = this.cache.get(keyId);
if (existing) existing.plaintextKey.fill(0);
this.cache.set(keyId, { plaintextKey, encryptedKey, createdAt: Date.now(), useCount: 1 });
}
flush(): void {
for (const entry of this.cache.values()) entry.plaintextKey.fill(0);
this.cache.clear();
}
}
const cache = new DataKeyCache(300, 1000);
async function encryptWithCache(plaintext: string, context: Record<string, string>): Promise<{
ciphertext: Buffer; encryptedKey: Buffer; iv: Buffer;
}> {
const cacheKey = `${KEY_ID}:${JSON.stringify(context)}`;
let dk = cache.get(cacheKey);
if (!dk) {
// Cache miss — one KMS call; subsequent calls within 300s reuse this key
const resp = await kms.send(new GenerateDataKeyCommand({
KeyId: KEY_ID, KeySpec: 'AES_256', EncryptionContext: context,
}));
dk = { plaintextKey: Buffer.from(resp.Plaintext!), encryptedKey: Buffer.from(resp.CiphertextBlob!),
createdAt: Date.now(), useCount: 1 };
cache.set(cacheKey, dk.plaintextKey, dk.encryptedKey);
}
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', dk.plaintextKey, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return { ciphertext: Buffer.concat([iv, authTag, encrypted]), encryptedKey: dk.encryptedKey, iv };
}
Note: all records encrypted with the same cached data key share a single encryptedKey blob. Store this blob once (e.g. as a key-wrapping record in a separate database table keyed by a UUID, referenced from each row) rather than storing a full copy of the ciphertext blob per record. A 50-byte blob per row × 10M rows = 500MB of redundant key blobs if stored naively. Store a UUID foreign key per row instead.
GenerateDataKeyWithoutPlaintext and ReEncrypt
GenerateDataKeyWithoutPlaintext generates a data key and returns only the encrypted version — no plaintext. Use this when you need to prepare an encrypted key for future use without the current process having the ability to decrypt data (e.g. pre-generating keys for scheduled batch jobs that will run with different credentials). The plaintext key is never visible to your code; it must be decrypted by the future process via a Decrypt call.
# Use case: prepare a data key for a Lambda function that will encrypt
# a large S3 object during a batch run — this process never sees plaintext
aws kms generate-data-key-without-plaintext \
--key-id alias/mcp-batch-encryption \
--key-spec AES_256 \
--encryption-context job=batch-export-001,env=production
# Returns only CiphertextBlob — store it; Lambda will Decrypt to get the plaintext key
# ReEncrypt: re-wrap ciphertext from one KMS key to another
# Use case: key rotation when you want to move ciphertext blobs to a new CMK
# without the application ever seeing the plaintext data key
aws kms re-encrypt \
--ciphertext-blob fileb://old-encrypted-key.bin \
--source-key-id alias/mcp-old-key \
--destination-key-id alias/mcp-new-key \
--source-encryption-context '{"tenantId":"abc"}' \
--destination-encryption-context '{"tenantId":"abc"}'
# Returns new CiphertextBlob wrapped under the new CMK
# The data itself is not touched — only the wrapped data key changes
ReEncrypt is the correct tool for migrating from one CMK to another — the plaintext data key is only visible inside KMS during the operation and never appears in your application or CloudTrail. When performing bulk key rotation, parallelize ReEncrypt calls in batches of 100 and backoff on ThrottlingException (KMS default: 5,000 symmetric requests/second per account per region).
Failure modes reference
| Failure | Symptom | Fix |
|---|---|---|
| 4KB limit on Encrypt | Encrypt call throws ValidationException: "Plaintext must be 4096 bytes or fewer" | Switch to GenerateDataKey + local AES-256-GCM; never call Encrypt directly on application data |
| Encryption context mismatch | Decrypt throws InvalidCiphertextException: "The encryption context in the request does not match" | Log the context dict used in GenerateDataKey at write time; ensure Decrypt passes identical context; check for extra whitespace or key ordering issues in serialized context |
| GCM authentication tag verification failure | decipher.final() throws ERR_CRYPTO_INVALID_AUTH_TAG | Ciphertext was modified in storage, or iv/authTag/ciphertext byte boundaries were stored/restored incorrectly; verify your concat/slice offsets match the write path |
| ThrottlingException on GenerateDataKey | Burst of encrypt calls hits KMS throttle (5,000 req/s symmetric) | Implement data key caching; add exponential backoff (start 100ms, max 10s, jitter); request limit increase from AWS support if sustained load >5,000 req/s |
| Plaintext key lingering in memory | Memory dump or core dump reveals AES key bytes adjacent to encrypted payloads | Fill plaintext Uint8Array with zeros immediately after encryption loop, not at garbage collection time; Buffer.from(resp.Plaintext) creates a copy — fill both the SDK buffer and your copy |
| InvalidCiphertextException with no context mismatch | Random Decrypt failures with no code changes | Ciphertext blob corruption — check base64 encoding round-trip (use binary storage, not base64 if avoidable); verify database column type is BLOB not VARCHAR (charset conversion corrupts binary) |
| Data key cache collisions across tenants | Tenant A can decrypt records belonging to Tenant B | Cache key must include tenant ID from encryption context; never cache a data key under a key that ignores tenant dimension |