Introduction
When we build cloud applications that handle sensitive data like passwords, API tokens, financial records, or personal information we need a way to encrypt and protect that data. That’s where AWS Key Management Service (KMS) comes in.
AWS KMS is a fully managed encryption service that helps us create, manage, and control cryptographic keys used to protect our data.
Normally, we use KMS for:
- Encrypting or decrypting small pieces of data (like secrets or credentials)
- Managing encryption keys centrally in AWS
- Integrating encryption easily with other AWS services like S3, RDS, or Lambda
While KMS can encrypt data directly, it only handles data smaller than 4KB, so when it comes to encryptin large volumes of data like files, logs, or customer records then KMS becomes inefficient and expensive because KMS is designed to handle key operations not bulk encryption.
That’s where Envelope Encryption comes in, it is a clever pattern that combines KMS for key management with local encryption for performance.
The Logic Behind Envelope Encryption
At its core, envelope encryption is about using two layers of encryption: one for the data and one for the key that encrypts it.

Encryption Process

Decryption Process
Here’s how it works conceptually:
- We ask AWS KMS to generate a data encryption key (DEK).
- KMS returns two versions of that key:
- A plaintext key, which we use temporarily to encrypt our data locally
- An encrypted key, which is secured by our Customer Master Key (CMK) stored in KMS
- We store the encrypted data and the encrypted key together.
- When we need to decrypt, we send the encrypted key to KMS to get the plaintext DEK back and use it to decrypt the data.
With this approach we get the best of both worlds, KMS securely stores and manages our keys and our application does the fast encryption and decryption work.
Implementing Envelope Encryption
In this section, we’ll walk through how to implement Envelope Encryption using the AWS SDK v3 along with Node’s built-in crypto module.
Step 1: Setup
To begin, install the required dependencies and ensure that your AWS credentials are correctly configured.
npm install @aws-sdk/client-kmsThen, import the required modules :
import { KMSClient, GenerateDataKeyCommand, DecryptCommand } from "@aws-sdk/client-kms";
import crypto from "crypto";
const kms = new KMSClient({ region: "us-east-1" });
const keyId = "arn:aws:kms:us-east-1:123456789012:key/your-kms-key-id";The keyId represents the Amazon Resource Name (ARN) of the KMS key you created in the AWS Management Console, as shown in the image below.

Example of an AWS KMS key policy:
{
"Version": "2012-10-17",
"Id": "key-default-1",
"Statement": [
{
"Sid": "Allow account root full access",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:root"
},
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "Allow specific user to use the key",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:user/app-user"
},
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey"
],
"Resource": "*"
}
]
}Step 2: Generate a Data Key
In this step, we will use a KMS feature called GenerateDataKey. It will create a new data key and return two versions: a plaintext key that we will use to encrypt our data, and an encrypted version that we will store securely.
async function generateDataKey() {
const command = new GenerateDataKeyCommand({
KeyId: keyId,
KeySpec: "AES_256", //generate a 256-bit AES key
});
const { Plaintext, CiphertextBlob } = await kms.send(command);
return {
plaintextKey: Plaintext,
encryptedKey: CiphertextBlob,
};
}Later, we will send the encrypted key back to KMS to decrypt it when we need to access the data again.
Step 3: Encrypt Data Locally
First, we’ll use the plaintext data key to encrypt our data on the local machine. To make the encryption more secure, we’ll generate a small random value called an initialization vector (IV). This ensures that even if we encrypt the same data twice, the result will always be different.
function encryptData(plaintextKey, data) {
const iv = crypto.randomBytes(12); // Initialization vector
const cipher = crypto.createCipheriv("aes-256-gcm", plaintextKey, iv);
const encrypted = Buffer.concat([
cipher.update(data, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return {
iv: iv.toString("base64"),
tag: tag.toString("base64"),
encryptedData: encrypted.toString("base64"),
};
}After running this function, we’ll get three values: the IV, a tag used to verify the data’s integrity, and the encrypted text itself. These will be saved together so we can later decrypt the data safely, more explanation in the table bellow :
| Element | Role | Why Needed |
|---|---|---|
| IV (Initialization Vector) | Adds randomness to each encryption operation | Prevents identical plaintexts from producing identical ciphertexts |
| Tag (Authentication Tag) | Ensures data integrity and authenticity | Detects if the ciphertext or associated data has been tampered with |
| Ciphertext (encryptedData) | Encrypted form of the plaintext data | Contains the actual encrypted content that can only be decrypted with the key |
Step 4: Decrypt Data Key via KMS
For the decrypting process we need first a function that decrypt the encrypted DEK to be used itself in decrypting the data.
async function decryptDataKey(encryptedKey) {
const command = new DecryptCommand({
CiphertextBlob: encryptedKey,
});
const { Plaintext } = await kms.send(command);
return Plaintext;
}And for decryption the data locally we will use the following function
function decryptData(plaintextKey, { iv, tag, encryptedData }) {
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
plaintextKey,
Buffer.from(iv, "base64")
);
decipher.setAuthTag(Buffer.from(tag, "base64"));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(encryptedData, "base64")),
decipher.final(),
]);
return decrypted.toString("utf8");
}Orchestrating the Complete Workflow
The last step is to orchestrate all the previous steps together to test the entire workflow
async function main(){
const { plaintextKey, encryptedKey } = await generateDataKey();
const secretData = "Hello, this is a top secret message";
const encrypted = encryptData(plaintextKey, secretData);
console.log("Encrypted Data:", encrypted);
// Simulate saving the encrypted data and the encrypted key in DB...
// decrypt the DEK and the data
const decryptedKey = await decryptDataKey(encryptedKey);
const decryptedText = decryptData(decryptedKey, encrypted);
console.log("Decrypted Text:", decryptedText);
}
main()
Result of executing the main function
As shown in the image above, the script is running correctly. The next step is to integrate this mechanism into our applications so it can work seamlessly as part of the overall system.
Best Practices
To maintain strong data protection and efficient key management, the following best practices are recommended when using KMS:
- Rotate KMS keys regularly to reduce exposure if a key is ever compromised.
- Avoid logging or storing plaintext data keys in any system to prevent accidental disclosure.
- Use KMS aliases to simplify key management and streamline key updates.
- Restrict access to sensitive actions such as kms:GenerateDataKey and kms:Decrypt through tightly controlled IAM permissions.
Conclusion
AWS KMS is one of the most powerful and underrated services when it comes to securing cloud applications. It gives us a simple way to manage encryption keys, enforce security policies, and protect sensitive data without reinventing cryptography.
By pairing KMS with the Envelope Encryption , we can encrypt massive amounts of data efficiently while keeping AWS in control of the keys.
