Envelope encryption is a technique that uses two layers of encryption to protect sensitive data. The first layer encrypts the data using a symmetric encryption algorithm with a randomly generated Data Encryption Key (DEK), which secures the actual plaintext. The second layer then encrypts, or wraps, the DEK using a Key Encryption Key (KEK). The KEK is typically a long-lived key stored and protected inside a KMS or HSM, and it may be either symmetric or asymmetric depending on the system. This two-tiered approach ensures the security of both the DEK and the encrypted data. Envelope encryption is widely used as part of centralized key management systems (KMS) provided by major cloud platforms.
In this article, we will explore how this technique works through a simple example implementation.
What Are The Benefits Of Envelope Encryption?
Stronger security: Security is the primary benefit of this technique. In most production environments, the Key Encryption Key (KEK) is stored and protected inside a KMS backed by HSMs, ensuring that it never leaves the secure hardware boundary. Because the KEK is required to unwrap the Data Encryption Key (DEK), an attacker cannot decrypt the ciphertext even if they obtain the encrypted data—the DEK itself cannot be recovered without the KEK.
Simplified key management: Simplified key management is another major advantage. Envelope encryption allows an organization to manage many Data Encryption Keys (DEKs) under one or more Key Encryption Keys (KEKs), greatly reducing operational complexity. KEKs can be rotated without re-encrypting all the data, since existing encrypted DEKs continue to work with older KEK versions. This makes large-scale key rotation feasible and minimizes impact on the underlying data storage.
Encryption Flow
Now that we’ve covered the fundamentals of envelope encryption, let’s walk through a simple implementation to demonstrate how to encrypt sensitive data — such as a credit card number—using this technique.
// Fake card number
String plainText = "5105105105105100";
The first step is to generate a random key (the Data Encryption Key, or DEK), which will be used to encrypt the plaintext using a symmetric algorithm such as AES-GCM.
private static final String KEY_ALGORITHM = "AES";
private static final int AES_KEY_LENGTH = 256;
//...
KeyGenerator keyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM);
keyGenerator.init(AES_KEY_LENGTH);
SecretKey dataEncryptionKey = keyGenerator.generateKey();
Next, encrypt the input data using the AES-GCM algorithm with the generated DEK.
//...
private static final String ENC_ALGORITHM = "AES/GCM/NoPadding";
//...
byte[] bPlainText = plainText.getBytes(StandardCharsets.UTF_8);
Cipher cipher = Cipher.getInstance(ENC_ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, dataEncryptionKey);
byte[] iv = cipher.getIV();
byte[] cipherText = cipher.doFinal(bPlainText);
To safely store the IV and the ciphertext, encode them using Base64:
String encodedIv = Base64.getEncoder().encodeToString(iv);
String encodedCipherText = Base64.getEncoder().encodeToString(cipherText);
Now we have the encrypted data and the IV used during encryption. Here is what the output looks like:
Base64 Encoded IV: g4xPa5FCgkw+KzmT
Base64 Encoded Cipher: fWegEZo4dlmm1YHvAe+njcZrcz10r5W9ntEEpQK5OgA=
Wrapping the Data Encryption Key (DEK)
Up to this point, we’ve covered the first layer of envelope encryption, which encrypts the input data. Now we’re ready for the second layer—wrapping the Data Encryption Key itself.
In production, KEKs are generated and stored inside KMS services backed by HSMs. Since the KEK never leaves secure hardware, the application cannot directly access it.
For illustration, we’ll use a simplified in-memory KMS shown below for the example implementation.
package com.example;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.KeyGenerator;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import java.security.GeneralSecurityException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public class InMemoryKMS {
private static final String KEY_ALGORITHM = "AES";
private static final int AES_KEY_LENGTH = 256;
private static final String WRAP_TRANSFORMATION = "AESWrap";
private final Map<String, List<SecretKey>> keyStore = new HashMap<>();
public String createKEK() {
SecretKey key = generateKey();
String keyId = UUID.randomUUID().toString();
keyStore.computeIfAbsent(keyId, k -> new ArrayList<>()).add(key);
return keyId;
}
public void rotateKEK(String keyId) {
List<SecretKey> versions = keyStore.get(keyId);
if (versions == null) {
throw new IllegalArgumentException("Unknown keyId: " + keyId);
}
SecretKey newKek = generateKey();
versions.add(newKek);
}
public byte[] wrapDEK(String keyId, SecretKey dataEncryptionKey) {
try {
SecretKey activeKek = getLatestKey(keyId);
Cipher cipher = Cipher.getInstance(WRAP_TRANSFORMATION);
cipher.init(Cipher.WRAP_MODE, activeKek);
return cipher.wrap(dataEncryptionKey);
} catch (NoSuchPaddingException | IllegalBlockSizeException | NoSuchAlgorithmException |
InvalidKeyException e) {
throw new RuntimeException(e);
}
}
public SecretKey unwrapDEK(String keyId, byte[] wrappedDataEncryptionKey) {
try {
List<SecretKey> versions = keyStore.get(keyId);
if (versions == null) {
throw new IllegalArgumentException("Unknown keyId: " + keyId);
}
// Try each KEK version (newest first), just like cloud KMS
for (int i = versions.size() - 1; i >= 0; i--) {
SecretKey kek = versions.get(i);
try {
Cipher cipher = Cipher.getInstance(WRAP_TRANSFORMATION);
cipher.init(Cipher.UNWRAP_MODE, kek);
return (SecretKey) cipher.unwrap(wrappedDataEncryptionKey, KEY_ALGORITHM, Cipher.SECRET_KEY);
} catch (GeneralSecurityException ignored) {
// Try next version
}
}
throw new IllegalStateException("Unable to unwrap DEK with any KEK version");
} catch (Exception e) {
throw new IllegalStateException("Failed to unwrap DEK", e);
}
}
private SecretKey generateKey() {
try {
KeyGenerator generator = KeyGenerator.getInstance(KEY_ALGORITHM);
generator.init(AES_KEY_LENGTH);
return generator.generateKey();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
private SecretKey getLatestKey(String keyId) {
List<SecretKey> versions = keyStore.get(keyId);
if (versions == null || versions.isEmpty()) {
throw new IllegalArgumentException("Unknown keyId: " + keyId);
}
return versions.getLast();
}
}
Now we are ready to wrap the Data Encryption Key previously generated using our in memory KMS.
InMemoryKMS kms = new InMemoryKMS();
String keyId = kms.createKEK();
byte[] wrappedDEK = kms.wrapDEK(keyId, dataEncryptionKey);
To store the wrapped DEK later safely, encode it with Base64.
String encodedWrappedDEK = Base64.getEncoder().encodeToString(wrappedDEK);
As we completed the encryption process, we should have the following output which we can safely transfer or store, and use for description later.
Base64 Encoded IV: +W1d4YVNBBJFDuaB
Base64 Encoded Cipher: Wib4Hx96rSBn7dTXrrdKEb3BTxbUdDm7PPCxybc20/I=
KEK ID: eeeb0c35-b166-4680-a800-3a28641fb03c
Base64 Encoded WrappedDEK: dzQCY+x+JsDdHpscjf1qiWgm2utGEwNFIBIX6QiWqW/mjIRP42jFgw==
Decryption Flow
During encryption, we stored the randomly generated initialization vector (IV), the ciphertext, the wrapped Data Encryption Key (DEK), and the identifier of the Key Encryption Key (KEK) managed by the in-memory KMS. Because the KEK never leaves the KMS, the application cannot directly decrypt the DEK or the ciphertext on its own. To recover the original data, we must first ask the KMS to unwrap the DEK.
byte[] wrappedDek = Base64.getDecoder().decode(encodedWrappedDEK);
SecretKey dataEncryptionKey = kms.unwrapDEK(kekId, wrappedDek);
At this point, we have reconstructed the original DEK. Keep in mind that the DEK should never be stored or persisted after it is unwrapped; it should exist only in memory for as short a time as possible.
We can decrypt the ciphertext using the unwrapped DEK.
byte[] iv = Base64.getDecoder().decode(encodedIv);
byte[] cipherText = Base64.getDecoder().decode(encodedCipherText);
Cipher cipher = Cipher.getInstance(ENC_ALGORITHM);
GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, dataEncryptionKey, gcmParameterSpec);
byte[] decrypted = cipher.doFinal(cipherText);
String decryptedText = new String(decrypted, StandardCharsets.UTF_8);
System.out.println("Decrypted Text: " + decryptedText);
And finally, we can see the original data as shown below:
Decrypted Text: 5105105105105100
A Note on Key Rotation:
Since key rotation is a broader topic within KMS systems, we won’t cover it in depth in this article. However, briefly put: when the KMS rotates a key, it creates a new KEK version and designates it as the active key for future wrapping operations. Older KEK versions remain available for unwrapping, ensuring that previously wrapped DEKs can still be decrypted. This design allows key rotation without requiring re-encryption of existing data.
Final Thoughts:
Envelope encryption provides a strong and scalable way to protect sensitive data while simplifying key management. It is the foundation of most modern cloud KMS systems.
As always, you can find complete code example in the following repository.
Thanks for reading!
Credits
- AES/GCM encryption in java
- Envelope encryption
- Hybrid cryptosystem
- Cover Photo by Mauro Sbicego on Unsplash
- Special thanks to Alexandru Pătrănescu
Top comments (0)