Envelope encryption with a cloud KMS: CLI workflow end to end
For developers who need application-level encryption without storing raw data keys. This walks through generating a data encryption key from a cloud KMS, encrypting data locally with AES-256-GCM, storing the encrypted data key alongside the ciphertext, and decrypting it later with exact commands and verification steps.
TL;DR — Use your cloud KMS only to generate and unwrap short-lived data keys, and do the bulk encryption locally with AES-256-GCM. The most common failure is losing or changing the encryption context/AAD between encrypt and decrypt; store it with the ciphertext metadata and pass it back exactly. Reading time: ~5 min
Goal
When you finish, you will have a working envelope-encryption flow on your machine: a cloud KMS key generates a data key, your app encrypts plaintext locally with AES-256-GCM, you store the encrypted data key plus IV/tag/ciphertext, and you can decrypt the payload end to end only with KMS access.
Prerequisites
- A cloud account with KMS access and permission to use an existing symmetric encryption key
- One KMS key identifier at hand:
- AWS: a KMS key ARN or alias like
alias/app-prod-envelope - GCP: a key resource like
projects/PROJECT/locations/global/keyRings/RING/cryptoKeys/KEY - Azure: a Key Vault key identifier like
https://VAULT.vault.azure.net/keys/KEY/VERSION
- AWS: a KMS key ARN or alias like
- CLI installed and authenticated:
awsCLI v2 — check withaws --version- or
gcloudCLI — check withgcloud version - or
azCLI — check withaz version
openssl3.x — check withopenssl versionjq1.6+ — check withjq --versionbase64and a POSIX shell- A plaintext file to protect, for example
secret.txt - For AWS-specific commands below: permission for
kms:GenerateDataKeyandkms:Decrypt
Steps
Step 1: Create a plaintext file and choose fixed metadata
Use a stable encryption context/AAD value now; you must use the exact same value on decrypt.
printf 'customer_ssn=123-45-6789\n' > secret.txt
export KEY_ID='alias/app-prod-envelope'
export ENC_CTX_KEY='purpose'
export ENC_CTX_VAL='customer-pii'
What you should see when this succeeds: secret.txt exists and cat secret.txt prints one line of test data.
Step 2: Ask KMS for a data encryption key
This example uses AWS KMS because it exposes the full envelope flow directly from the CLI. If you use another provider, the pattern is the same: generate or wrap a symmetric data key with KMS, then encrypt locally.
aws kms generate-data-key \
--key-id "$KEY_ID" \
--key-spec AES_256 \
--encryption-context "$ENC_CTX_KEY=$ENC_CTX_VAL" \
--output json > dek.json
jq -r '.Plaintext' dek.json | base64 --decode > dek.bin
jq -r '.CiphertextBlob' dek.json > dek.ciphertext.b64
Typical successful output shape in dek.json:
{
"CiphertextBlob": "AQICAHj...snip...==",
"Plaintext": "m7j2Jm...snip...==",
"KeyId": "arn:aws:kms:us-east-1:123456789012:key/11111111-2222-3333-4444-555555555555"
}
What you should see when this succeeds: dek.bin is exactly 32 bytes and wc -c dek.bin prints 32.
Step 3: Encrypt the plaintext locally with AES-256-GCM
Generate a 12-byte IV, encrypt with the plaintext DEK, and capture the authentication tag separately.
openssl rand 12 > iv.bin
openssl enc -aes-256-gcm -e \
-in secret.txt \
-out secret.ciphertext.bin \
-K "$(xxd -p -c 256 dek.bin)" \
-iv "$(xxd -p -c 256 iv.bin)" \
-nosalt \
-aad "${ENC_CTX_KEY}=${ENC_CTX_VAL}" \
-tagout tag.bin
What you should see when this succeeds: secret.ciphertext.bin, iv.bin, and tag.bin exist, and wc -c tag.bin prints 16.
Step 4: Build the payload you will store
Store the wrapped DEK, IV, tag, ciphertext, and the exact AAD/encryption context used.
jq -n \
--arg alg 'AES-256-GCM' \
--arg kms_key_id "$KEY_ID" \
--arg enc_ctx_key "$ENC_CTX_KEY" \
--arg enc_ctx_val "$ENC_CTX_VAL" \
--arg wrapped_dek "$(cat dek.ciphertext.b64)" \
--arg iv "$(base64 < iv.bin | tr -d '\n')" \
--arg tag "$(base64 < tag.bin | tr -d '\n')" \
--arg ciphertext "$(base64 < secret.ciphertext.bin | tr -d '\n')" \
'{alg:$alg,kms_key_id:$kms_key_id,encryption_context:{($enc_ctx_key):$enc_ctx_val},wrapped_dek:$wrapped_dek,iv:$iv,tag:$tag,ciphertext:$ciphertext}' > envelope.json
cat envelope.json | jq .
What you should see when this succeeds: envelope.json is valid JSON with fields wrapped_dek, iv, tag, and ciphertext.
Step 5: Delete the plaintext DEK before testing decrypt
This is the whole point of envelope encryption: the raw DEK should not live longer than needed.
⚠️ This step deletes local key material. Do not run it until
envelope.jsonexists and you have verified it contains the wrapped DEK.
shred -u dek.bin 2>/dev/null || rm -f dek.bin
ls dek.bin
Expected output on success:
ls: cannot access 'dek.bin': No such file or directory
What you should see when this succeeds: dek.bin is gone.
Step 6: Unwrap the DEK with KMS and decrypt the payload
Read the wrapped DEK from the envelope, ask KMS to decrypt it with the same encryption context, then decrypt the ciphertext locally.
jq -r '.wrapped_dek' envelope.json | base64 --decode > wrapped_dek.bin
aws kms decrypt \
--ciphertext-blob fileb://wrapped_dek.bin \
--encryption-context "$ENC_CTX_KEY=$ENC_CTX_VAL" \
--output json > unwrapped.json
jq -r '.Plaintext' unwrapped.json | base64 --decode > dek.bin
jq -r '.iv' envelope.json | base64 --decode > iv.bin
jq -r '.tag' envelope.json | base64 --decode > tag.bin
jq -r '.ciphertext' envelope.json | base64 --decode > secret.ciphertext.bin
openssl enc -aes-256-gcm -d \
-in secret.ciphertext.bin \
-out secret.decrypted.txt \
-K "$(xxd -p -c 256 dek.bin)" \
-iv "$(xxd -p -c 256 iv.bin)" \
-nosalt \
-aad "${ENC_CTX_KEY}=${ENC_CTX_VAL}" \
-tag "$(xxd -p -c 256 tag.bin)"
What you should see when this succeeds: secret.decrypted.txt exists and matches secret.txt byte-for-byte.
Step 7: Clean up local sensitive files
Leave only the envelope payload if that is what your app stores.
shred -u dek.bin secret.txt secret.decrypted.txt iv.bin tag.bin secret.ciphertext.bin wrapped_dek.bin 2>/dev/null || rm -f dek.bin secret.txt secret.decrypted.txt iv.bin tag.bin secret.ciphertext.bin wrapped_dek.bin unwrapped.json dek.json
ls -1
What you should see when this succeeds: only envelope.json remains from this exercise.
Verify it works
Run the full decrypt path from the stored envelope and compare the recovered plaintext to the original expected value.
export ENC_CTX_KEY='purpose'
export ENC_CTX_VAL='customer-pii'
jq -r '.wrapped_dek' envelope.json | base64 --decode > wrapped_dek.bin
aws kms decrypt \
--ciphertext-blob fileb://wrapped_dek.bin \
--encryption-context "$ENC_CTX_KEY=$ENC_CTX_VAL" \
--query Plaintext \
--output text | base64 --decode > dek.bin
jq -r '.iv' envelope.json | base64 --decode > iv.bin
jq -r '.tag' envelope.json | base64 --decode > tag.bin
jq -r '.ciphertext' envelope.json | base64 --decode > secret.ciphertext.bin
openssl enc -aes-256-gcm -d \
-in secret.ciphertext.bin \
-out - \
-K "$(xxd -p -c 256 dek.bin)" \
-iv "$(xxd -p -c 256 iv.bin)" \
-nosalt \
-aad "${ENC_CTX_KEY}=${ENC_CTX_VAL}" \
-tag "$(xxd -p -c 256 tag.bin)"
Expected output:
customer_ssn=123-45-6789
A successful openssl enc ... -d exits with code 0. If authentication fails, OpenSSL exits non-zero and prints a message like:
bad decrypt
80DBA0C2E67F0000:error:1C800066:Provider routines:ossl_gcm_stream_update:cipher operation failed:../providers/implementations/ciphers/ciphercommon_gcm.c:325:
Common pitfalls
Encryption context/AAD changed between encrypt and decrypt
Mistake: using purpose=customer-pii during generate-data-key and purpose=customer_pii or no AAD during decrypt.
Symptom: aws kms decrypt returns InvalidCiphertextException, or OpenSSL prints bad decrypt even though KMS returned a plaintext key.
One-line fix: pass the exact same --encryption-context "purpose=customer-pii" to KMS and the exact same -aad "purpose=customer-pii" to OpenSSL.
Base64-decoding the wrapped DEK twice or not at all
Mistake: feeding the base64 string directly to fileb:// or decoding it twice before kms decrypt.
Symptom: AWS CLI returns an error like:
An error occurred (InvalidCiphertextException) when calling the Decrypt operation:
One-line fix: store CiphertextBlob as base64 text, then decode it exactly once with base64 --decode > wrapped_dek.bin before --ciphertext-blob fileb://wrapped_dek.bin.
Reusing an IV with the same DEK
Mistake: encrypting multiple records with the same iv.bin and same plaintext DEK.
Symptom: no immediate error; you silently weaken confidentiality and integrity guarantees.
One-line fix: generate a fresh 12-byte IV for every encryption with openssl rand 12 > iv.bin.
Using the wrong OpenSSL flags for GCM
Mistake: omitting -tagout on encrypt or passing the tag in the wrong format on decrypt.
Symptom: decrypt fails with bad decrypt, or you have ciphertext but no recoverable tag.
One-line fix: on encrypt use -tagout tag.bin; on decrypt pass -tag "$(xxd -p -c 256 tag.bin)".
Leaving plaintext DEKs on disk or in logs
Mistake: saving dek.json, unwrapped.json, or shell-debug output in CI artifacts.
Symptom: security review finds raw DEKs in build logs or temp directories.
One-line fix: delete dek.bin, dek.json, and unwrapped.json immediately after use, and do not run these commands with shell tracing like set -x.
Assuming KMS encrypts large payloads directly
Mistake: sending application payloads to KMS instead of only wrapping a DEK.
Symptom: poor throughput, size-limit errors, or high KMS request volume.
One-line fix: use KMS only for GenerateDataKey/Decrypt of the DEK and keep bulk encryption local with AES-256-GCM.
View as text
[App] ---- 1. GenerateDataKey ----> [Cloud KMS] [App] <---- 2. Plaintext DEK + wrapped DEK ---- [Cloud KMS] [App] | 3. AES-256-GCM encrypt with plaintext DEK v [Envelope JSON] | 4. later: send wrapped DEK to decrypt v [Cloud KMS] | 5. return plaintext DEK v [App] ---- 6. AES-256-GCM decrypt ----> [Plaintext]
Trade-offs worth knowing:
- Store the wrapped DEK next to each ciphertext if you need independent record-level decryption and key rotation by rewrapping later.
- If you rotate the KMS key, old wrapped DEKs remain decryptable unless you disable or schedule deletion of the old KMS key; plan retention accordingly.
- AES-256-GCM gives confidentiality plus integrity, but only if you preserve
iv,tag, and AAD exactly and never reuse an IV with the same DEK. - For production apps, use your language crypto library or a vetted SDK rather than shelling out to
openssl; the flow stays the same, but key bytes should remain in memory, not temp files.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI