KMS Envelope Encryption in Lambda: Generate Data Keys Once, Encrypt Without Repeated KMS Calls
Use KMS GenerateDataKey to encrypt customer data locally in Lambda, store the encrypted data key with the ciphertext, and avoid per-record KMS calls. Includes a working Python example and verification steps.
12 May 2026, 04:39 UTC

The problem: encrypting customer data without hammering KMS
If your Lambda function encrypts customer records before writing them to S3 or DynamoDB, calling kms:Encrypt on every item works but couples your throughput to KMS request quotas and per-call latency. The supported pattern is envelope encryption: ask KMS for a data key once, encrypt locally with that key, and store the encrypted copy of the key next to the ciphertext. Decryption later requires only one kms:Decrypt call to recover the data key.
The useful takeaway: KMS never exposes your customer master key (CMK, now called a KMS key). It hands you a plaintext data key plus an encrypted copy of that same key. You use the plaintext immediately, keep the encrypted copy, and discard the plaintext from memory.
How envelope encryption works
A call to GenerateDataKey returns two things: a plaintext data key (typically 256 bits for AES-256-GCM) and that key encrypted under your KMS key. The plaintext exists only in that response. You encrypt your payload locally with a library such as the AWS Encryption SDK or cryptography, then persist the encrypted data key alongside the ciphertext — for example, as an S3 object metadata field or a DynamoDB attribute.
To decrypt later, you send the stored encrypted data key to kms:Decrypt, get the plaintext data key back, and decrypt locally. The KMS key itself never leaves KMS, and access to decryption is governed entirely by IAM and key policy.
A worked Lambda example
The following Python function generates a data key at cold start, encrypts a record, and writes the ciphertext plus the encrypted data key to S3. Run it as a Lambda with the cryptography package bundled in a layer or container image (boto3 is included in the Python runtime).
import boto3, os, base64
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
kms = boto3.client("kms")
s3 = boto3.client("s3")
KEY_ARN = os.environ["KMS_KEY_ARN"]
BUCKET = os.environ["DATA_BUCKET"]
# Cold start: generate a data key for this execution environment
key_resp = kms.generate_data_key(KeyId=KEY_ARN, KeySpec="AES_256")
PLAINTEXT_KEY = key_resp["Plaintext"]
ENCRYPTED_KEY = key_resp["CiphertextBlob"]
def handler(event, context):
aesgcm = AESGCM(PLAINTEXT_KEY)
nonce = os.urandom(12)
ciphertext = aesgcm.encrypt(nonce, event["record"].encode(), None)
s3.put_object(
Bucket=BUCKET,
Key=f"records/{event['id']}.bin",
Body=nonce + ciphertext,
Metadata={"data-key": base64.b64encode(ENCRYPTED_KEY).decode()},
)
return {"status": "ok"}
The Lambda execution role needs kms:GenerateDataKey and kms:Decrypt on that specific key ARN. A missing permission surfaces as an AccessDeniedException at cold start, which is the most common deployment failure. Use IAM Access Analyzer's policy simulation to confirm the role's effective permissions before deploying.
Key reuse across warm invocations
Generating one data key per execution environment (as above) keeps KMS calls low: one call per cold start instead of one per record. Each record still gets a unique nonce, which is what AES-GCM actually requires for safety — never reuse a (key, nonce) pair. If your threat model requires per-record keys, call GenerateDataKey per record, but note the quota: KMS API request quotas are per account per region (commonly thousands of calls per second for GenerateDataKey, varying by region), and exceeding them raises ThrottlingException. Check your region's current quota in the Service Quotas console rather than assuming a number.
Common mistakes
- Logging the plaintext key. The
Plaintextfield in the response must never reach CloudWatch Logs or environment variables. Only theCiphertextBlobis safe to persist. - Storing only the ciphertext. If you discard the encrypted data key, the data is unrecoverable — KMS cannot regenerate the same data key.
- Cross-region assumptions. KMS keys are regional. An encrypted data key produced in
us-east-1cannot be decrypted ineu-west-1unless you use a multi-Region key or re-encrypt under a key in the target region. - Missing key policy grants. The role needs permission in both IAM policy and the key policy (unless the key policy delegates to IAM, which is the default for new keys).
Verifying the round trip
Before trusting the pipeline, run a local check with credentials that have the same KMS permissions. This script verifies that key generation and a full encrypt/decrypt round trip succeed:
import boto3, os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
kms = boto3.client("kms", region_name="us-east-1")
resp = kms.generate_data_key(KeyId="arn:aws:kms:us-east-1:123456789012:key/your-key-id", KeySpec="AES_256")
assert len(resp["Plaintext"]) == 32
nonce = os.urandom(12)
ct = AESGCM(resp["Plaintext"]).encrypt(nonce, b"hello", None)
# Simulate later decryption using only the stored encrypted key
pt_key = kms.decrypt(CiphertextBlob=resp["CiphertextBlob"])["Plaintext"]
assert AESGCM(pt_key).decrypt(nonce, ct, None) == b"hello"
print("round trip OK")
Run this from any environment with AWS credentials (CloudShell works). Expected result: no exception and "round trip OK". An AccessDeniedException points to IAM or key policy; a ThrottlingException means you are hitting the request quota.
Limits to plan around
Envelope encryption adds one KMS call at cold start and one per decryption of a previously unseen encrypted key, so latency-sensitive decrypt paths should cache recovered plaintext keys in memory with a bounded lifetime. The encrypted data key adds roughly a few hundred bytes per record. And because the plaintext key lives in your function's memory, anyone who can read that memory (via a runtime compromise) can decrypt data encrypted under it — envelope encryption protects data at rest, not data in a compromised process.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.