A working note by Kevin Jorquera. Last revisited August 27, 2026.
The problem KMS actually solves
“Encrypt it with KMS” sounds like a complete design until the payload is larger than a secret or a database field. The AWS KMS Encrypt API accepts at most 4,096 bytes, and sending every application payload to KMS would turn a key-management service into a data-processing bottleneck.
The better mental model is that KMS protects keys, and a local symmetric cipher protects data. AWS calls this envelope encryption:
- Ask KMS to generate a one-time data encryption key (DEK).
- KMS returns two copies: one plaintext and one encrypted by the KMS key.
- Use the plaintext DEK locally with AES-GCM to encrypt the payload.
- Remove the plaintext DEK from memory as soon as possible.
- Store the encrypted DEK beside the IV and ciphertext.
AWS KMS
┌────────────┐
│ KMS key │
└─────┬──────┘
│ wraps / unwraps
▼
Application ───────► plaintext DEK ───────► AES-256-GCM ───────► ciphertext
│ │ │
└─────────────────────┴──── store only ────────────────────────┘
encrypted DEK + IV + ciphertext
The encrypted DEK is safe to store with the ciphertext. Decryption reverses the flow: KMS unwraps the DEK, then the application uses that plaintext DEK locally to decrypt the data. KMS does not store or track the data keys it generates, so the encrypted copy is part of the application’s durable record.
Why AES-GCM and encryption context both matter
AES-GCM provides confidentiality and integrity. If someone modifies the ciphertext, IV, authentication tag, or additional authenticated data, decryption fails instead of returning corrupted plaintext.
KMS encryption context gives the wrapped data key a similar binding. It is a map of non-secret values sent to both GenerateDataKey and Decrypt. KMS cryptographically binds that exact context to the encrypted DEK, and the key policy can require part of it.
For a customer API, a useful context might look like this:
{
"application": "customer-api",
"recordId": "d0f4aa48-76a1-47e8-82e7-841fdf3c349f"
}
The stable application value lets the KMS policy limit which workload may use the key. The recordId stops an encrypted value from being silently moved to another record. The application also supplies that record ID to AES-GCM as additional authenticated data.
Build the KMS key as a Terraform module
The module below creates a symmetric customer-managed KMS key, enables automatic rotation, gives the account permission to administer the key through IAM, and grants one application role only the two cryptographic actions this design needs.
The module
# modules/kms-envelope-key/main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
}
}
}
data "aws_caller_identity" "current" {}
variable "alias_name" {
description = "KMS alias, including the alias/ prefix."
type = string
validation {
condition = startswith(var.alias_name, "alias/")
error_message = "alias_name must start with alias/."
}
}
variable "application_role_arn" {
description = "IAM role assumed by the application at runtime."
type = string
}
variable "application_context" {
description = "Required value for the application encryption context."
type = string
}
variable "tags" {
description = "Tags to apply to the KMS key."
type = map(string)
default = {}
}
data "aws_iam_policy_document" "key" {
statement {
sid = "EnableAccountAdministration"
effect = "Allow"
actions = ["kms:*"]
resources = ["*"]
principals {
type = "AWS"
identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"]
}
}
statement {
sid = "AllowApplicationEnvelopeEncryption"
effect = "Allow"
actions = [
"kms:GenerateDataKey",
"kms:Decrypt"
]
resources = ["*"]
principals {
type = "AWS"
identifiers = [var.application_role_arn]
}
condition {
test = "StringEquals"
variable = "kms:EncryptionContext:application"
values = [var.application_context]
}
}
}
resource "aws_kms_key" "this" {
description = "Envelope encryption key for ${var.application_context}"
key_usage = "ENCRYPT_DECRYPT"
enable_key_rotation = true
deletion_window_in_days = 30
policy = data.aws_iam_policy_document.key.json
tags = var.tags
}
resource "aws_kms_alias" "this" {
name = var.alias_name
target_key_id = aws_kms_key.this.key_id
}
output "key_arn" {
description = "ARN to provide to the application."
value = aws_kms_key.this.arn
}
output "alias_arn" {
description = "ARN of the readable KMS alias."
value = aws_kms_alias.this.arn
}
There are two different meanings of "*" in that policy. In the administration statement, kms:* intentionally delegates key administration to the account; lock down who can receive those IAM permissions. In a KMS key policy, resources = ["*"] means the key to which the policy is attached. It does not grant the application access to every KMS key.
The application statement is much narrower. It names one principal, permits only data-key generation and decryption, and requires the expected encryption context. It does not grant kms:Encrypt, kms:CreateKey, kms:PutKeyPolicy, or deletion permissions.
Call the module
# encryption.tf
module "customer_data_kms" {
source = "./modules/kms-envelope-key"
alias_name = "alias/customer-api-data"
application_role_arn = aws_iam_role.customer_api.arn
application_context = "customer-api"
tags = {
Application = "customer-api"
ManagedBy = "terraform"
DataClass = "confidential"
}
}
output "customer_data_kms_key_arn" {
value = module.customer_data_kms.key_arn
}
Pass the output ARN to the workload as KMS_KEY_ARN. On ECS, EKS, Lambda, or EC2, let the workload assume its IAM role and allow the AWS SDK’s default credential chain to obtain short-lived credentials. Do not add access keys to Terraform variables or Spring configuration.
Wire KMS into Spring Boot
The Java example uses AWS SDK for Java 2.x and the JDK’s AES-GCM implementation. Import the AWS SDK BOM at the version approved by your organization, then add only the KMS client module:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>${aws.sdk.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>kms</artifactId>
</dependency>
</dependencies>
Bind the key ARN and application name from environment-backed configuration:
# application.yml
security:
kms:
key-id: ${KMS_KEY_ARN}
application-name: customer-api
package com.infoseciq.crypto;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.services.kms.KmsClient;
@ConfigurationProperties(prefix = "security.kms")
record KmsProperties(String keyId, String applicationName) {}
@Configuration
@EnableConfigurationProperties(KmsProperties.class)
class KmsConfiguration {
@Bean(destroyMethod = "close")
KmsClient kmsClient() {
return KmsClient.create();
}
}
KmsClient.create() uses the SDK’s default Region and credential provider chains. In AWS, that normally means the task, pod, function, or instance role. For local development, use an AWS profile or another short-lived identity rather than copying long-lived keys into application.yml.
Define the envelope you will store
An encrypted value needs enough metadata to be decryptable years later. Version the format from day one, record the algorithm, and store the actual key ARN returned by KMS—not only an alias that could later point somewhere else.
package com.infoseciq.crypto;
public record EncryptedEnvelope(
int version,
String algorithm,
String keyId,
String encryptedDataKey,
String iv,
String ciphertext
) {}
Serialized as JSON, a stored envelope will look roughly like this:
{
"version": 1,
"algorithm": "AES/GCM/NoPadding",
"keyId": "arn:aws:kms:us-east-1:111122223333:key/1234abcd-...",
"encryptedDataKey": "AQICAHh...",
"iv": "rbLJJn7w8fS5Hq0v",
"ciphertext": "iN7x8eLCr5qV..."
}
The GCM authentication tag is included in the byte array returned by Java’s Cipher#doFinal, so it is already part of ciphertext here.
Encrypt and decrypt in the service
This service generates a fresh AES-256 data key for each call, uses a random 96-bit IV, binds the operation to the application and authoritative record ID, and clears its local plaintext key copy in a finally block.
package com.infoseciq.crypto;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.services.kms.KmsClient;
import software.amazon.awssdk.services.kms.model.DataKeySpec;
import software.amazon.awssdk.services.kms.model.DecryptRequest;
import software.amazon.awssdk.services.kms.model.GenerateDataKeyRequest;
import software.amazon.awssdk.services.kms.model.GenerateDataKeyResponse;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import java.util.Map;
import static java.nio.charset.StandardCharsets.UTF_8;
@Service
public final class EnvelopeEncryptionService {
private static final int VERSION = 1;
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final int GCM_TAG_BITS = 128;
private static final int IV_BYTES = 12;
private final KmsClient kms;
private final KmsProperties properties;
private final SecureRandom secureRandom = new SecureRandom();
private final Base64.Encoder encoder = Base64.getEncoder();
private final Base64.Decoder decoder = Base64.getDecoder();
public EnvelopeEncryptionService(KmsClient kms, KmsProperties properties) {
this.kms = kms;
this.properties = properties;
}
public EncryptedEnvelope encrypt(String recordId, byte[] plaintext)
throws GeneralSecurityException {
Map<String, String> context = encryptionContext(recordId);
GenerateDataKeyResponse generated = kms.generateDataKey(
GenerateDataKeyRequest.builder()
.keyId(properties.keyId())
.keySpec(DataKeySpec.AES_256)
.encryptionContext(context)
.build()
);
byte[] plaintextKey = generated.plaintext().asByteArray();
try {
byte[] iv = new byte[IV_BYTES];
secureRandom.nextBytes(iv);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(
Cipher.ENCRYPT_MODE,
new SecretKeySpec(plaintextKey, "AES"),
new GCMParameterSpec(GCM_TAG_BITS, iv)
);
cipher.updateAAD(recordId.getBytes(UTF_8));
byte[] ciphertext = cipher.doFinal(plaintext);
return new EncryptedEnvelope(
VERSION,
ALGORITHM,
generated.keyId(),
encoder.encodeToString(generated.ciphertextBlob().asByteArray()),
encoder.encodeToString(iv),
encoder.encodeToString(ciphertext)
);
} finally {
Arrays.fill(plaintextKey, (byte) 0);
}
}
public byte[] decrypt(String recordId, EncryptedEnvelope envelope)
throws GeneralSecurityException {
validateEnvelope(envelope);
Map<String, String> context = encryptionContext(recordId);
byte[] plaintextKey = kms.decrypt(
DecryptRequest.builder()
.keyId(envelope.keyId())
.ciphertextBlob(SdkBytes.fromByteArray(
decoder.decode(envelope.encryptedDataKey())
))
.encryptionContext(context)
.build()
).plaintext().asByteArray();
try {
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(
Cipher.DECRYPT_MODE,
new SecretKeySpec(plaintextKey, "AES"),
new GCMParameterSpec(
GCM_TAG_BITS,
decoder.decode(envelope.iv())
)
);
cipher.updateAAD(recordId.getBytes(UTF_8));
return cipher.doFinal(decoder.decode(envelope.ciphertext()));
} finally {
Arrays.fill(plaintextKey, (byte) 0);
}
}
private Map<String, String> encryptionContext(String recordId) {
return Map.of(
"application", properties.applicationName(),
"recordId", recordId
);
}
private void validateEnvelope(EncryptedEnvelope envelope) {
if (envelope.version() != VERSION || !ALGORITHM.equals(envelope.algorithm())) {
throw new IllegalArgumentException("Unsupported encrypted envelope");
}
}
}
The recordId should come from the trusted record being read—for example, the database primary key in the query—not from an attacker-controlled field inside the envelope. If someone copies an envelope to another record, either the KMS context check or the local GCM additional-data check will fail.
In an API, translate that failure into a generic error. Do not log the plaintext, plaintext data key, full ciphertext, or exception details that might expose sensitive material. Emit a metric and retain the KMS request ID or trace correlation ID for investigation.
What rotation does—and does not—do
With enable_key_rotation = true, AWS KMS periodically creates new backing key material for the same logical KMS key. New GenerateDataKey calls use the current material, while KMS retains the older material needed to decrypt existing encrypted DEKs. The key ARN and application code do not change.
Rotation does not re-encrypt existing payloads or replace their data keys. It also does not fix a compromised plaintext DEK. If policy requires a completely new KMS key, point the alias at the new key for future writes and keep the old key enabled until its data has been rewrapped or retired.
Production hardening checklist
- Use one KMS key per trust boundary, not one universal key for the company.
- Keep key administrators separate from application roles that only use the key.
- Scope the application principal to a specific key and require encryption context.
- Use a fresh DEK and IV for every encrypted value or object.
- Store the encrypted DEK, IV, algorithm, format version, and actual KMS key ARN.
- Add CloudTrail alerts for key-policy changes, disablement, and scheduled deletion.
- Treat KMS throttling and transient service failures as retryable with bounded backoff.
- Test ciphertext tampering, wrong-record context, disabled-key behavior, and disaster recovery.
- Never reuse plaintext data keys unless you have deliberately designed and bounded a cryptographic-materials cache.
The design in one sentence
Use KMS to control who may unwrap a short-lived key, use authenticated symmetric encryption to protect the actual data, and store only the encrypted pieces needed to put the envelope back together.
That separation keeps large payloads out of KMS, makes access auditable, limits the blast radius of a data key, and gives the application a storage format it can evolve deliberately.
Further reading
- AWS KMS cryptography essentials and envelope encryption
- AWS KMS data keys
- AWS KMS encryption context
- AWS KMS least-privilege guidance
- Terraform
aws_kms_keyresource - AWS Encryption SDK for Java examples
Thanks for reading.
If this helped you see the system differently, the note did its job.