Envelope Encryption and KMS Patterns

Envelope Encryption and KMS Patterns - article cover image Cloud Security
Time it takes to read this article 6 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

Envelope encryption is the standard pattern for encrypting data at scale without ever sending the plaintext data itself to a key management service (KMS): instead of encrypting every object directly with a master key, the application generates a fresh, random data encryption key (DEK) per object, encrypts the object locally with that DEK, then asks the KMS to wrap (encrypt) the DEK with a key encryption key (KEK) that never leaves the KMS/HSM boundary. The wrapped DEK is stored alongside the ciphertext; decryption means asking the KMS to unwrap the DEK (an authorized, audited, low-bandwidth API call) and then decrypting the object locally with the returned plaintext DEK.

This pattern is why AWS KMS, Google Cloud KMS, Azure Key Vault, and HashiCorp Vault’s transit engine all expose an Encrypt/Decrypt API limited to small payloads (a few KB) alongside a GenerateDataKey/GenerateDataKey style call — they are explicitly designed around envelope encryption rather than bulk data encryption through the KMS itself. Misunderstanding this design is where most of the real-world bugs in cloud encryption architectures come from: encrypting large objects directly through the KMS (slow, expensive, sometimes simply unsupported past a size limit), reusing DEKs across many objects, or granting decrypt permissions so broadly that the KMS layer stops providing any real access control.

Attack Prerequisites

Abusing an envelope-encryption implementation generally requires:

  • Overly broad IAM/KMS policy grants — a role or service with kms:Decrypt (or the DEK-unwrap equivalent) on a key it does not operationally need, often via a wildcard resource or an overly permissive key policy/grant.
  • Access to the wrapped DEK and ciphertext — typically both are stored together in the same object store or database row, so compromising storage access (an S3 bucket, a database) yields both halves needed to decrypt, *if* KMS decrypt is also reachable.
  • A DEK reused across many records, or a static/hard-coded DEK bypassing the KMS entirely — turns a single compromise into a mass-decryption event instead of one record.
  • Missing or unchecked encryption context — KMS supports binding additional authenticated data (AAD) to a wrap/unwrap call; without it, a wrapped DEK can be replayed/reused outside its intended context.

How It Works

The write path: the application calls the KMS GenerateDataKey API, which returns two things — a plaintext DEK (used immediately and then discarded from memory) and that same DEK encrypted under the KEK (the *ciphertext blob*, safe to store). The application encrypts the actual object locally using the plaintext DEK with an AEAD cipher (AES-256-GCM is standard), stores the object ciphertext plus the wrapped DEK together, and never persists the plaintext DEK anywhere. The KEK itself never leaves the KMS/HSM boundary — all the KMS ever does with it is wrap and unwrap small DEK-sized payloads, which is why the pattern scales to encrypting arbitrarily large and numerous objects while making only small, auditable calls to the KMS.

The read path reverses this: the application reads the wrapped DEK from storage, calls KMS Decrypt on that small blob (this is the call that IAM policy actually gates), receives the plaintext DEK back, and uses it locally to decrypt the object. Critically, KMS access control operates on the unwrap call, not on the underlying data — the KMS has no idea what object a given DEK protects. This means the entire access-control model for the encrypted data collapses onto however tightly kms:Decrypt permissions are scoped; if a role can unwrap the DEK, it can decrypt every object protected by that DEK, regardless of any application-level authorization logic that may or may not also be enforced.

Encryption context (AWS KMS) or equivalent AAD in other KMS implementations lets the application bind non-secret metadata (e.g. {"tenant": "acme-corp", "object-id": "12345"}) to the wrap/unwrap operation cryptographically — the same context must be supplied on decrypt or the operation fails, and the context appears in KMS audit logs (CloudTrail for AWS), giving defenders a verifiable link between *which* data a given decrypt call was for. Omitting this is not fatal to confidentiality, but it removes a valuable integrity/audit binding and a cheap way to make DEKs non-transferable across contexts.

Vulnerable Code / Configuration

A KMS key policy that grants decrypt to an overly broad principal set — here, an entire AWS account rather than the specific application role that needs it:

{
  "Sid": "OverlyBroadDecrypt",
  "Effect": "Allow",
  "Principal": {"AWS": "arn:aws:iam::111122223333:root"},
  "Action": ["kms:Decrypt", "kms:GenerateDataKey*"],
  "Resource": "*"
}
JSON

Application code that sidesteps envelope encryption entirely by caching and reusing a single plaintext DEK across every object, which turns a per-object compromise into a full-dataset compromise and defeats the point of per-object keys:

# VULNERABLE: one DEK generated once, reused for every object forever
_cached_dek = kms.generate_data_key(KeyId=KMS_KEY_ID, KeySpec='AES_256')
PLAINTEXT_DEK = _cached_dek['Plaintext']       # kept in memory long-term

def encrypt_object(data: bytes) -> bytes:
    nonce = os.urandom(12)
    aesgcm = AESGCM(PLAINTEXT_DEK)
    return nonce + aesgcm.encrypt(nonce, data, None)
    # every object encrypted under the SAME key -> nonce collision risk,
    # and a single DEK leak decrypts the entire dataset
Python

The correct pattern generates a fresh DEK per object (or per reasonably bounded batch) and stores the wrapped DEK next to the ciphertext, with encryption context bound to the object’s identity:

def encrypt_object(data: bytes, tenant_id: str, object_id: str) -> dict:
    ctx = {'tenant': tenant_id, 'object-id': object_id}
    resp = kms.generate_data_key(
        KeyId=KMS_KEY_ID, KeySpec='AES_256', EncryptionContext=ctx)
    plaintext_dek = resp['Plaintext']
    wrapped_dek = resp['CiphertextBlob']
    nonce = os.urandom(12)
    ciphertext = AESGCM(plaintext_dek).encrypt(nonce, data, None)
    del plaintext_dek                       # discard immediately
    return {'wrapped_dek': wrapped_dek, 'nonce': nonce, 'ciphertext': ciphertext,
            'context': ctx}
Python

Walkthrough / Exploitation

Given AWS credentials for an over-permissioned role, an attacker first enumerates reachable KMS keys and their policies to find broad decrypt grants:

aws kms list-keys
aws kms get-key-policy --key-id <key-id> --policy-name default
aws kms list-grants --key-id <key-id>
Bash

With storage access (e.g. leaked S3 credentials or a misconfigured bucket policy) and reachable kms:Decrypt, the attacker retrieves the wrapped DEK and ciphertext object, then simply calls KMS to unwrap it — the exact same call the legitimate application makes:

aws s3 cp s3://victim-bucket/objects/12345.enc ./obj.enc
aws s3 cp s3://victim-bucket/objects/12345.dek ./wrapped.dek

aws kms decrypt --ciphertext-blob fileb://wrapped.dek \
  --output text --query Plaintext | base64 -d > plaintext.dek
Bash

If encryption context was used and enforced, the same decrypt call fails unless the attacker also knows and supplies the exact context — a real, if modest, additional barrier and, more importantly, a call that shows up clearly in CloudTrail tied to the specific tenant/object context, aiding detection even if the decrypt itself succeeds because the IAM policy was too broad.

Note: Envelope encryption does not replace access control — it relocates it to the KMS. If kms:Decrypt is grantable via a role assumable by too many principals, or a key policy uses a wildcard principal, the encryption layer adds latency and audit trail but not real confidentiality protection against a compromised principal within that grant.

Opsec: CloudTrail records every KMS Decrypt/GenerateDataKey call with the calling principal and encryption context (if used) — during an authorized assessment, expect KMS decrypt calls against sensitive keys to be a monitored, alertable action, and account for that in the engagement’s detection/notification plan.

Detection and Defense

A sound envelope-encryption deployment combines correct cryptography with tight access control and monitoring:

  • Scope KMS key policies and IAM grants narrowly — specific roles, specific actions (Decrypt vs GenerateDataKey vs Encrypt), no wildcard principals, and use grants with expiration for temporary access rather than standing broad policy statements.
  • Generate a unique DEK per object (or per small, bounded batch), never a single long-lived DEK cached and reused across the entire dataset.
  • Use encryption context / AAD to bind each wrap/unwrap call to the object/tenant it protects, and enforce it — this both prevents cross-context DEK replay and enriches audit logs.
  • Enable and monitor KMS audit logging (CloudTrail, Cloud Audit Logs, Azure Monitor) for unexpected principals or unusual volumes of Decrypt/GenerateDataKey calls, and alert on access from outside expected regions/roles.
  • Separate storage access from KMS access in your threat model — assume an attacker may get one but not the other, and verify that neither alone is sufficient to recover plaintext (this is the entire value proposition of envelope encryption; misconfiguration is what erodes it).
  • Rotate KEKs on a defined schedule and support automatic re-wrapping of DEKs, without needing to re-encrypt the underlying bulk data.

Real-World Impact

Envelope encryption is the default architecture behind AWS S3 server-side encryption with KMS (SSE-KMS), Google Cloud’s default storage encryption, and most enterprise “bring your own key” cloud offerings, which is why misconfigured KMS key policies and IAM grants are a routine, high-impact finding in cloud security assessments — the encryption itself is rarely the weak point, but overly broad decrypt permissions repeatedly undermine it in real environments, effectively turning strong encryption at rest into encryption in name only for any principal within the broad grant.

Conclusion

Envelope encryption lets applications encrypt data at scale while keeping the actual master key inside a KMS/HSM boundary, but the pattern only delivers real security when DEKs are unique per object, encryption context binds each unwrap to its intended use, and KMS access grants are scoped as tightly as any other high-value credential. Treat kms:Decrypt/equivalent permissions with the same rigor as direct data access, because functionally, for anyone holding both the ciphertext and that permission, that is exactly what it is.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL