Quick take — A reusable Terraform module for AWS KMS customer-managed keys: automatic rotation, deletion windows, multi-Region replicas, aliases, and a least-privilege key policy you control instead of the default root grant. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "kms" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-kms?ref=v1.0.0"
alias_name = "..." # Alias without the `alias/` prefix; also used as the Nam…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS KMS (Key Management Service) gives you customer-managed keys (CMKs) that encrypt data at rest and in transit across services like S3, EBS, RDS, Secrets Manager, DynamoDB, and SQS. The core resource, aws_kms_key, is deceptively small — but the details that actually matter in production are easy to get wrong: the key policy (which by default hands the entire account root principal kms:*), automatic key rotation, the deletion window, the key spec for symmetric versus asymmetric use, and a human-readable alias so nobody references a raw key UUID in application config.
Wrapping aws_kms_key in a module forces every key in your estate through the same opinionated defaults: rotation on, a sane deletion window, a key policy assembled from explicit administrator and user role ARNs rather than a blanket root grant, and a consistent alias/<name> naming convention. It also makes optional-but-common features — like a multi-Region primary key for cross-Region failover, bypassing the policy lockout check, and grants for via service conditions — toggles instead of copy-pasted blocks. This module covers the symmetric-encryption default plus the optional asymmetric and HMAC specs, an alias, and an optional cross-Region replica key.
When to use it
- You need a customer-managed key (not an AWS-managed
aws/<service>key) so you control rotation, policy, and the audit trail in CloudTrail. - You want envelope encryption for S3 buckets, EBS volumes, RDS/Aurora storage, Secrets Manager secrets, or SNS/SQS with a key whose policy you govern.
- You require automatic annual key rotation for compliance (PCI-DSS, HIPAA, SOC 2, FedRAMP) and want it enforced module-wide.
- You operate in multiple Regions and need a multi-Region key so the same key material (and therefore the same ciphertext) is usable in a DR Region.
- You want to standardize key aliases and tags across dozens of keys instead of letting each team invent its own naming.
Reach for an AWS-managed key instead when you do not need custom policies or rotation control and you want zero key-management overhead — but you lose policy customization and pay nothing, whereas a CMK costs roughly USD 1/month plus per-request charges.
Module structure
terraform-module-aws-kms/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
configuration_aliases = [aws.replica]
}
}
}
main.tf
locals {
# Default key policy: scope admin (key management) and usage to explicit
# role ARNs instead of granting the whole account root kms:*.
default_key_policy = jsonencode({
Version = "2012-10-17"
Id = "key-policy-${var.alias_name}"
Statement = concat(
[
{
Sid = "EnableRootAccountForBreakGlass"
Effect = "Allow"
Principal = { AWS = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:root" }
Action = "kms:*"
Resource = "*"
# Root is kept ONLY so the account cannot lock itself out, but it is
# constrained: it may not use the key for crypto operations directly.
Condition = {
StringEquals = { "kms:CallerAccount" = data.aws_caller_identity.current.account_id }
}
}
],
length(var.key_administrator_arns) > 0 ? [
{
Sid = "KeyAdministrators"
Effect = "Allow"
Principal = { AWS = var.key_administrator_arns }
Action = [
"kms:Create*", "kms:Describe*", "kms:Enable*", "kms:List*",
"kms:Put*", "kms:Update*", "kms:Revoke*", "kms:Disable*",
"kms:Get*", "kms:Delete*", "kms:TagResource", "kms:UntagResource",
"kms:ScheduleKeyDeletion", "kms:CancelKeyDeletion"
]
Resource = "*"
}
] : [],
length(var.key_user_arns) > 0 ? [
{
Sid = "KeyUsers"
Effect = "Allow"
Principal = { AWS = var.key_user_arns }
Action = [
"kms:Encrypt", "kms:Decrypt", "kms:ReEncrypt*",
"kms:GenerateDataKey*", "kms:DescribeKey"
]
Resource = "*"
},
{
Sid = "AllowAttachmentOfPersistentResources"
Effect = "Allow"
Principal = { AWS = var.key_user_arns }
Action = ["kms:CreateGrant", "kms:ListGrants", "kms:RevokeGrant"]
Resource = "*"
Condition = {
Bool = { "kms:GrantIsForAWSResource" = "true" }
}
}
] : [],
length(var.service_principals) > 0 ? [
{
Sid = "AllowServiceUseViaGrant"
Effect = "Allow"
Principal = { Service = var.service_principals }
Action = [
"kms:Encrypt", "kms:Decrypt", "kms:ReEncrypt*",
"kms:GenerateDataKey*", "kms:DescribeKey", "kms:CreateGrant"
]
Resource = "*"
}
] : []
)
})
key_policy = var.policy_override != null ? var.policy_override : local.default_key_policy
# Rotation is only valid for symmetric encryption keys.
rotation_enabled = var.key_usage == "ENCRYPT_DECRYPT" && var.customer_master_key_spec == "SYMMETRIC_DEFAULT" ? var.enable_key_rotation : false
}
data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}
resource "aws_kms_key" "this" {
description = var.description
key_usage = var.key_usage
customer_master_key_spec = var.customer_master_key_spec
deletion_window_in_days = var.deletion_window_in_days
enable_key_rotation = local.rotation_enabled
rotation_period_in_days = local.rotation_enabled ? var.rotation_period_in_days : null
multi_region = var.multi_region
is_enabled = var.is_enabled
policy = local.key_policy
bypass_policy_lockout_safety_check = var.bypass_policy_lockout_safety_check
tags = merge(
var.tags,
{
Name = var.alias_name
ManagedBy = "terraform"
}
)
}
resource "aws_kms_alias" "this" {
name = "alias/${var.alias_name}"
target_key_id = aws_kms_key.this.key_id
}
# Optional cross-Region replica of a multi-Region primary key. Shares key
# material with the primary so ciphertext is portable between Regions.
resource "aws_kms_replica_key" "this" {
count = var.multi_region && var.create_replica ? 1 : 0
provider = aws.replica
primary_key_arn = aws_kms_key.this.arn
description = "${var.description} (replica)"
deletion_window_in_days = var.deletion_window_in_days
policy = local.key_policy
tags = merge(
var.tags,
{
Name = "${var.alias_name}-replica"
ManagedBy = "terraform"
}
)
}
resource "aws_kms_alias" "replica" {
count = var.multi_region && var.create_replica ? 1 : 0
provider = aws.replica
name = "alias/${var.alias_name}"
target_key_id = aws_kms_replica_key.this[0].key_id
}
variables.tf
variable "alias_name" {
description = "Alias for the key WITHOUT the 'alias/' prefix (e.g. 'prod/app-data'). Also used as the Name tag."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9/_-]+$", var.alias_name)) && !startswith(var.alias_name, "aws/")
error_message = "alias_name may contain only alphanumerics, '/', '_', '-', and must not start with the reserved 'aws/' prefix."
}
}
variable "description" {
description = "Human-readable description of the key's purpose."
type = string
default = "Customer-managed KMS key managed by Terraform"
}
variable "key_usage" {
description = "Intended use of the key. ENCRYPT_DECRYPT for data encryption, SIGN_VERIFY for asymmetric signing, GENERATE_VERIFY_MAC for HMAC."
type = string
default = "ENCRYPT_DECRYPT"
validation {
condition = contains(["ENCRYPT_DECRYPT", "SIGN_VERIFY", "GENERATE_VERIFY_MAC"], var.key_usage)
error_message = "key_usage must be one of ENCRYPT_DECRYPT, SIGN_VERIFY, or GENERATE_VERIFY_MAC."
}
}
variable "customer_master_key_spec" {
description = "Key spec. SYMMETRIC_DEFAULT for standard encryption; RSA_*/ECC_* for asymmetric; HMAC_* for MACs."
type = string
default = "SYMMETRIC_DEFAULT"
validation {
condition = contains([
"SYMMETRIC_DEFAULT",
"RSA_2048", "RSA_3072", "RSA_4096",
"ECC_NIST_P256", "ECC_NIST_P384", "ECC_NIST_P521", "ECC_SECG_P256K1",
"HMAC_224", "HMAC_256", "HMAC_384", "HMAC_512"
], var.customer_master_key_spec)
error_message = "customer_master_key_spec must be a valid KMS key spec."
}
}
variable "deletion_window_in_days" {
description = "Waiting period (7-30 days) before a scheduled key deletion is permanent."
type = number
default = 30
validation {
condition = var.deletion_window_in_days >= 7 && var.deletion_window_in_days <= 30
error_message = "deletion_window_in_days must be between 7 and 30."
}
}
variable "enable_key_rotation" {
description = "Enable automatic annual rotation of key material. Ignored for non-symmetric keys."
type = bool
default = true
}
variable "rotation_period_in_days" {
description = "Rotation interval in days (90-2560). Only applies when rotation is enabled on a symmetric key."
type = number
default = 365
validation {
condition = var.rotation_period_in_days >= 90 && var.rotation_period_in_days <= 2560
error_message = "rotation_period_in_days must be between 90 and 2560."
}
}
variable "multi_region" {
description = "Create the key as a multi-Region PRIMARY key so it can be replicated to other Regions."
type = bool
default = false
}
variable "create_replica" {
description = "When multi_region is true, also create a replica key in the aws.replica Region."
type = bool
default = false
}
variable "is_enabled" {
description = "Whether the key is enabled and usable for cryptographic operations."
type = bool
default = true
}
variable "bypass_policy_lockout_safety_check" {
description = "Skip the check that prevents you from making the key unmanageable. Leave false unless you know why you need it."
type = bool
default = false
}
variable "key_administrator_arns" {
description = "IAM role/user ARNs allowed to administer (manage, not use) the key."
type = list(string)
default = []
}
variable "key_user_arns" {
description = "IAM role/user ARNs allowed to use the key for encrypt/decrypt/generate-data-key operations."
type = list(string)
default = []
}
variable "service_principals" {
description = "AWS service principals (e.g. 'logs.amazonaws.com') allowed to use the key via grants."
type = list(string)
default = []
}
variable "policy_override" {
description = "Full JSON key policy. When set, completely replaces the module's generated least-privilege policy."
type = string
default = null
}
variable "tags" {
description = "Tags applied to the key and its replica."
type = map(string)
default = {}
}
outputs.tf
output "key_id" {
description = "The globally unique identifier (UUID) of the KMS key."
value = aws_kms_key.this.key_id
}
output "key_arn" {
description = "The ARN of the KMS key. Use this when wiring services like S3, RDS, or Secrets Manager."
value = aws_kms_key.this.arn
}
output "alias_name" {
description = "The full alias including the 'alias/' prefix."
value = aws_kms_alias.this.name
}
output "alias_arn" {
description = "The ARN of the key alias."
value = aws_kms_alias.this.arn
}
output "rotation_enabled" {
description = "Whether automatic key rotation is effectively enabled on this key."
value = aws_kms_key.this.enable_key_rotation
}
output "multi_region" {
description = "Whether the key is a multi-Region key."
value = aws_kms_key.this.multi_region
}
output "replica_key_arn" {
description = "ARN of the cross-Region replica key, or null if no replica was created."
value = try(aws_kms_replica_key.this[0].arn, null)
}
How to use it
provider "aws" {
region = "ap-south-1"
}
# Second provider for the multi-Region replica (DR Region).
provider "aws" {
alias = "dr"
region = "ap-southeast-1"
}
module "kms_key" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-kms?ref=v1.0.0"
providers = {
aws.replica = aws.dr
}
alias_name = "prod/app-data"
description = "Encrypts the production application data S3 bucket and Secrets Manager secrets"
deletion_window_in_days = 30
enable_key_rotation = true
rotation_period_in_days = 365
multi_region = true
create_replica = true
key_administrator_arns = [
"arn:aws:iam::123456789012:role/platform-kms-admins"
]
key_user_arns = [
"arn:aws:iam::123456789012:role/app-runtime"
]
service_principals = ["secretsmanager.amazonaws.com"]
tags = {
Environment = "production"
CostCenter = "platform"
DataClass = "confidential"
}
}
# Downstream: encrypt an S3 bucket with the module's key_arn output.
resource "aws_s3_bucket" "app_data" {
bucket = "kloudvin-prod-app-data"
}
resource "aws_s3_bucket_server_side_encryption_configuration" "app_data" {
bucket = aws_s3_bucket.app_data.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = module.kms_key.key_arn
}
bucket_key_enabled = true
}
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/kms/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-kms?ref=v1.0.0"
}
inputs = {
alias_name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/kms && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
alias_name |
string |
— | Yes | Alias without the alias/ prefix; also used as the Name tag. Must not start with aws/. |
description |
string |
"Customer-managed KMS key managed by Terraform" |
No | Human-readable description of the key’s purpose. |
key_usage |
string |
"ENCRYPT_DECRYPT" |
No | ENCRYPT_DECRYPT, SIGN_VERIFY, or GENERATE_VERIFY_MAC. |
customer_master_key_spec |
string |
"SYMMETRIC_DEFAULT" |
No | Key spec: symmetric, RSA/ECC asymmetric, or HMAC. |
deletion_window_in_days |
number |
30 |
No | Days (7–30) before a scheduled deletion becomes permanent. |
enable_key_rotation |
bool |
true |
No | Enable automatic rotation; ignored for non-symmetric keys. |
rotation_period_in_days |
number |
365 |
No | Rotation interval (90–2560) for symmetric keys. |
multi_region |
bool |
false |
No | Create as a multi-Region primary key. |
create_replica |
bool |
false |
No | Create a replica in the aws.replica Region (requires multi_region). |
is_enabled |
bool |
true |
No | Whether the key is enabled for crypto operations. |
bypass_policy_lockout_safety_check |
bool |
false |
No | Skip the lockout safety check; leave false unless required. |
key_administrator_arns |
list(string) |
[] |
No | IAM ARNs allowed to manage (not use) the key. |
key_user_arns |
list(string) |
[] |
No | IAM ARNs allowed to use the key for encrypt/decrypt. |
service_principals |
list(string) |
[] |
No | AWS service principals allowed to use the key via grants. |
policy_override |
string |
null |
No | Full JSON policy that replaces the generated least-privilege policy. |
tags |
map(string) |
{} |
No | Tags applied to the key and replica. |
Outputs
| Name | Description |
|---|---|
key_id |
The globally unique UUID of the KMS key. |
key_arn |
The ARN of the key; use this to wire S3, RDS, Secrets Manager, etc. |
alias_name |
The full alias including the alias/ prefix. |
alias_arn |
The ARN of the key alias. |
rotation_enabled |
Whether automatic rotation is effectively enabled. |
multi_region |
Whether the key is a multi-Region key. |
replica_key_arn |
ARN of the cross-Region replica, or null if none was created. |
Enterprise scenario
A fintech running active-active in ap-south-1 (Mumbai) and ap-southeast-1 (Singapore) needs ciphertext written in either Region to be readable in the other during a Regional failover. The platform team provisions one multi-Region primary key in Mumbai with multi_region = true and create_replica = true, so the Singapore replica shares the same key material. The key policy grants secretsmanager.amazonaws.com and the application runtime role usage rights, while only the platform-kms-admins role can administer the key — satisfying the PCI-DSS separation-of-duties control that key administrators must not be key users.
Best practices
- Never rely on the default root-only policy. This module replaces the implicit
root kms:*grant with explicit administrator and user role ARNs so key administration and key usage are separated — a recurring requirement in PCI-DSS and SOC 2 audits. - Keep automatic rotation on for symmetric keys. Annual rotation is free, transparent (old ciphertext stays decryptable), and the module enforces it by default; it correctly disables rotation for asymmetric and HMAC specs where AWS does not support it.
- Use the longest deletion window you can tolerate. The 30-day default maximizes the recovery window if a key is scheduled for deletion by mistake — once the window elapses, the key and every object it encrypted are unrecoverable.
- Enable
bucket_key_enabled(and S3 Bucket Keys generally) to cut cost. KMS bills per request; S3 Bucket Keys reduceGenerateDataKey/Decryptcalls by up to 99%, turning a noisy bucket from dollars-per-day into cents. - Standardize aliases as
alias/<env>/<purpose>. Applications and IAM policies reference the stable alias, never the rotating key UUID, which keeps config readable and decouples consumers from key recreation. - Scope
service_principalstightly and preferkms:ViaServiceconditions. Granting a service principal blanket access is broader than needed; restrict usage to specific resources and Regions inpolicy_overridefor high-sensitivity keys.