IaC AWS

Terraform Module: AWS KMS Key — governed customer-managed keys with rotation and least-privilege policies

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

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 configlive/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 configlive/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

TerraformAWSKMS KeyModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading