Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 091 / 100

KMS keys with Terraform: real-world lessons and examples

Encryption keys as code.

Expona Admin7 min read

Introduction

When you're handling sensitive data—database credentials, API keys, encryption secrets—you can't rely on application-level security alone. KMS keys with Terraform: real-world lessons and examples covers how to provision and manage AWS Key Management Service (KMS) customer-managed keys through infrastructure as code, treating encryption keys as first-class infrastructure resources. We've learned in production that KMS integration catches misconfigurations before they reach your database layer, and it integrates seamlessly with most AWS services without application rewrites.

Customer-Managed Keys for Sensitive Data

AWS KMS offers two key types: AWS-managed and customer-managed. AWS-managed keys are convenient for quick starts, but they don't give you control over key policies or audit trails. In production systems handling payment data, PII, or regulatory workloads, customer-managed keys are the baseline.

When we moved our last e-commerce system to customer-managed KMS keys, we gained visibility into who accessed which keys and when. Here's how we define a customer-managed key for a database encryption layer:

hcl
resource "aws_kms_key" "database_encryption" {
  description             = "Customer-managed key for RDS encryption"
  deletion_window_in_days = 7
  enable_key_rotation     = true

  tags = {
    Environment = "production"
    Purpose     = "rds-encryption"
  }
}

resource "aws_kms_alias" "database_encryption_alias" {
  name          = "alias/prod-rds-key"
  target_key_id = aws_kms_key.database_encryption.key_id
}

resource "aws_db_instance" "main" {
  identifier          = "prod-database"
  allocated_storage   = 100
  engine              = "postgres"
  engine_version      = "15.2"
  instance_class      = "db.r5.large"
  storage_encrypted   = true
  kms_key_id          = aws_kms_key.database_encryption.arn

  skip_final_snapshot = false
}

The deletion_window_in_days = 7 setting is critical: it prevents accidental key deletion and gives you a week to recover if something goes wrong. We've seen teams set this to 30 days for mission-critical keys.

Key Policies with Least Privilege

A key without the right policy is like giving everyone a key to the executive washroom. By default, the key creator owns everything, but you need to explicitly grant permissions to services and roles that actually use the key.

Let's say your RDS database needs to encrypt data, your Lambda functions need to decrypt logs, and your audit team needs read-only access to the key metadata. Here's a realistic policy structure:

hcl
data "aws_caller_identity" "current" {}

resource "aws_kms_key" "app_secrets" {
  description         = "Encryption key for application secrets"
  enable_key_rotation = true
}

resource "aws_kms_key_policy" "app_secrets_policy" {
  key_id = aws_kms_key.app_secrets.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "Enable IAM User Permissions"
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
        }
        Action   = "kms:*"
        Resource = "*"
      },
      {
        Sid    = "Allow RDS to use the key"
        Effect = "Allow"
        Principal = {
          Service = "rds.amazonaws.com"
        }
        Action = [
          "kms:Decrypt",
          "kms:DescribeKey",
          "kms:GenerateDataKey"
        ]
        Resource = "*"
      },
      {
        Sid    = "Allow Lambda execution role to decrypt"
        Effect = "Allow"
        Principal = {
          AWS = aws_iam_role.lambda_execution.arn
        }
        Action = [
          "kms:Decrypt",
          "kms:DescribeKey"
        ]
        Resource = "*"
      },
      {
        Sid    = "Allow audit role read-only access"
        Effect = "Allow"
        Principal = {
          AWS = aws_iam_role.audit_team.arn
        }
        Action = [
          "kms:DescribeKey",
          "kms:GetKeyPolicy",
          "kms:GetKeyRotationStatus",
          "kms:ListAliases"
        ]
        Resource = "*"
      },
      {
        Sid    = "Deny unencrypted uploads to S3"
        Effect = "Deny"
        Principal = {
          AWS = "*"
        }
        Action = "kms:*"
        Resource = "*"
        Condition = {
          Bool = {
            "kms:EncryptionContextEquals:s3:x-amz-server-side-encryption" = "false"
          }
        }
      }
    ]
  })
}

Notice the Deny unencrypted uploads statement at the end. This is a guardrail: even if someone accidentally uploads to S3 without encryption, this policy blocks it. We've caught misconfigurations this way before they hit production.

The key principle: each principal gets only the actions it needs. RDS doesn't need kms:CreateGrant, and your audit team doesn't need kms:Decrypt. This pattern has saved us from lateral movement and privilege escalation in security reviews.

Enable Key Rotation

Key rotation sounds like an operations burden, but AWS KMS handles it automatically. When you enable rotation, AWS creates a new key material version each year and rotates old data keys transparently. Your application doesn't see any downtime or performance hit.

We turn on rotation for every customer-managed key in production:

hcl
resource "aws_kms_key" "payment_data_key" {
  description             = "Key for encrypting payment processor responses"
  deletion_window_in_days = 10
  enable_key_rotation     = true

  tags = {
    Environment = "production"
    DataClass   = "pci-dss"
    Rotation    = "enabled"
  }
}

resource "aws_kms_key_rotation_status" "payment_data_rotation" {
  key_id            = aws_kms_key.payment_data_key.id
  enable_key_rotation = true
}

In compliance audits (SOC 2, PCI-DSS), auditors explicitly ask about key rotation. Enabling it in Terraform means it's documented, version-controlled, and consistent across all environments. We've also seen teams monitor the aws_kms_key_rotation_status data source to alert if rotation ever gets disabled unexpectedly.

Aliases for Readability

Key IDs are hex strings; key ARNs are long. When multiple services reference the same key, or when you're troubleshooting in CloudTrail logs, aliases make everything clearer. An alias like alias/prod-rds-key is immediately recognizable.

We use a naming convention: alias/environment-purpose-key. Here's a real example from a multi-environment setup:

hcl
locals {
  environments = ["dev", "staging", "production"]
  key_purposes = ["rds-encryption", "lambda-secrets", "s3-logs"]
}

resource "aws_kms_key" "env_keys" {
  for_each = toset(flatten([
    for env in local.environments : [
      for purpose in local.key_purposes : "${env}-${purpose}"
    ]
  ]))

  description             = "Managed key for ${each.key}"
  deletion_window_in_days = 7
  enable_key_rotation     = true

  tags = {
    ManagedByTerraform = "true"
    Purpose            = split("-", each.key)[1]
    Environment        = split("-", each.key)[0]
  }
}

resource "aws_kms_alias" "env_aliases" {
  for_each = aws_kms_key.env_keys

  name          = "alias/${each.key}"
  target_key_id = each.value.key_id
}

When a new team member looks at your Terraform state or AWS console, they instantly understand the key hierarchy. When debugging encryption failures in Lambda, searching CloudTrail for alias/prod-lambda-secrets is orders of magnitude faster than hunting through key IDs.

Common Mistakes

  • Overly permissive key policies: Using "Principal": "*" with broad actions defeats the purpose of customer-managed keys. Always specify service roles or specific ARNs.
  • Forgetting to set a deletion window: Defaulting to 7 days is usually fine, but explicitly setting deletion_window_in_days prevents surprise key deletions during refactors.
  • Disabling rotation "for now": We've seen teams disable rotation temporarily and forget to re-enable it. Make rotation a Terraform default; don't handle it manually.
  • Using AWS-managed keys when compliance requires customer-managed: SOC 2 Type II and PCI-DSS auditors will ask for proof of key control. Customer-managed keys are the only credible answer.
  • Not tagging keys: Without tags, cost allocation, audit trails, and automation become painful. Tag every key with environment, purpose, and data classification.
  • Hardcoding key ARNs in application code: Use Terraform outputs and aliases. When you rotate infrastructure or split databases, hardcoded ARNs force app changes.

Key Takeaways

  • Customer-managed KMS keys give you control over access, auditing, and key lifecycle—essential for regulated or sensitive workloads.
  • Write key policies with least privilege: each principal gets only the actions it needs, reducing blast radius if credentials leak.
  • Enable automatic key rotation in Terraform; AWS handles the mechanics transparently and compliance auditors expect it.
  • Use aliases consistently so your team, your logs, and your audit trails all speak the same language.
  • Treat KMS keys as infrastructure code: version them, tag them, test policy changes in non-prod environments first, and monitor for unauthorized access via CloudTrail.
---

ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles