Cross-Account S3 Replication with KMS Encryption
Scenario
Your compliance team requires that all objects uploaded to the production S3 bucket in Account A are automatically replicated to a disaster recovery vault bucket in Account B. Both buckets must use KMS encryption — with different CMKs, one per account. The replication must survive even if Account A is compromised.
- Use two Terraform provider aliases — one for each AWS account — to manage both accounts from a single configuration.
- In Account A: create a source S3 bucket (versioning enabled, SSE-KMS), a replication IAM role, and a replication configuration targeting the Account B bucket.
- In Account B: create a destination bucket (versioning enabled, SSE-KMS with its own CMK) and a bucket policy granting Account A's replication role s3:ReplicateObject, s3:ReplicateTags, and s3:ReplicateDelete.
- Grant Account A's replication role kms:GenerateDataKey on the destination KMS key so it can re-encrypt objects during replication.
- Test replication by uploading an object and verifying it appears in the destination. Output both bucket ARNs and the replication role ARN.
Additional Context
The replication role in Account A needs permissions in two different accounts: s3:GetObject from the source bucket (Account A) and s3:ReplicateObject to the destination bucket (Account B). The second permission is granted via a resource-based policy on the Account B bucket, not via an IAM policy in Account A. Cross-account access always requires both sides: an IAM permission in the originating account AND a resource policy in the target account.
S3 versioning is a hard prerequisite for replication — both source and destination buckets must have versioning enabled. Replication does not copy existing objects — it only replicates objects uploaded after the replication rule is created.
variables.tf
variable "aws_region" { type = string; default = "us-east-1" }
variable "source_account_id" { type = string; description = "Account A — where objects are uploaded." }
variable "dest_account_id" { type = string; description = "Account B — disaster-recovery vault." }
variable "source_profile" { type = string; description = "AWS CLI profile for Account A." }
variable "dest_profile" { type = string; description = "AWS CLI profile for Account B." }
variable "environment" { type = string; default = "prod" }providers.tf
# Account A — source (production)
provider "aws" {
alias = "account_a"
region = var.aws_region
profile = var.source_profile
}
# Account B — destination (DR vault)
provider "aws" {
alias = "account_b"
region = var.aws_region
profile = var.dest_profile
}main.tf (Account B — destination, must exist before Account A references it)
### ACCOUNT B RESOURCES ###
# KMS key for the DR vault bucket.
resource "aws_kms_key" "dest" {
provider = aws.account_b
description = "CMK for S3 DR vault — ${var.environment}"
enable_key_rotation = true
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{ Sid = "AccountBRoot"; Effect = "Allow"
Principal = { AWS = "arn:aws:iam::${var.dest_account_id}:root" }
Action = "kms:*"; Resource = "*" },
# Allow Account A's replication role to use this key for re-encryption.
{ Sid = "AllowReplicationRole"; Effect = "Allow"
Principal = { AWS = aws_iam_role.replication.arn }
Action = ["kms:GenerateDataKey", "kms:Decrypt"]; Resource = "*" }
]
})
}
resource "aws_kms_alias" "dest" {
provider = aws.account_b
name = "alias/s3-dr-${var.environment}"
target_key_id = aws_kms_key.dest.key_id
}
# Destination (DR vault) bucket in Account B.
resource "aws_s3_bucket" "dest" {
provider = aws.account_b
bucket = "dr-vault-${var.environment}-${var.dest_account_id}"
tags = { Environment = var.environment, Purpose = "dr-replication" }
}
resource "aws_s3_bucket_versioning" "dest" {
provider = aws.account_b
bucket = aws_s3_bucket.dest.id
versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_server_side_encryption_configuration" "dest" {
provider = aws.account_b
bucket = aws_s3_bucket.dest.id
rule { apply_server_side_encryption_by_default { sse_algorithm = "aws:kms"; kms_master_key_id = aws_kms_key.dest.arn } }
}
# Destination bucket policy — grants Account A's replication role cross-account write access.
resource "aws_s3_bucket_policy" "dest" {
provider = aws.account_b
bucket = aws_s3_bucket.dest.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowReplication"; Effect = "Allow"
Principal = { AWS = aws_iam_role.replication.arn }
Action = ["s3:ReplicateObject","s3:ReplicateTags","s3:ReplicateDelete","s3:GetBucketVersioning","s3:PutBucketVersioning"]
Resource = ["${aws_s3_bucket.dest.arn}","${aws_s3_bucket.dest.arn}/*"]
}]
})
}
### ACCOUNT A RESOURCES ###
# KMS key for the source production bucket.
resource "aws_kms_key" "source" {
provider = aws.account_a
description = "CMK for S3 source — ${var.environment}"
enable_key_rotation = true
}
resource "aws_kms_alias" "source" {
provider = aws.account_a
name = "alias/s3-source-${var.environment}"
target_key_id = aws_kms_key.source.key_id
}
# Source (production) bucket in Account A.
resource "aws_s3_bucket" "source" {
provider = aws.account_a
bucket = "prod-primary-${var.environment}-${var.source_account_id}"
tags = { Environment = var.environment, Purpose = "source" }
}
resource "aws_s3_bucket_versioning" "source" {
provider = aws.account_a
bucket = aws_s3_bucket.source.id
versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_server_side_encryption_configuration" "source" {
provider = aws.account_a
bucket = aws_s3_bucket.source.id
rule { apply_server_side_encryption_by_default { sse_algorithm = "aws:kms"; kms_master_key_id = aws_kms_key.source.arn } }
}
# Replication IAM role in Account A.
resource "aws_iam_role" "replication" {
provider = aws.account_a
name = "s3-replication-role-${var.environment}"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Effect = "Allow"; Principal = { Service = "s3.amazonaws.com" }; Action = "sts:AssumeRole" }]
})
}
resource "aws_iam_role_policy" "replication" {
provider = aws.account_a
name = "replication-policy"
role = aws_iam_role.replication.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{ Effect = "Allow"
Action = ["s3:GetReplicationConfiguration","s3:ListBucket"]
Resource = aws_s3_bucket.source.arn },
{ Effect = "Allow"
Action = ["s3:GetObjectVersionForReplication","s3:GetObjectVersionAcl","s3:GetObjectVersionTagging"]
Resource = "${aws_s3_bucket.source.arn}/*" },
{ Effect = "Allow"
Action = ["kms:Decrypt"]; Resource = aws_kms_key.source.arn },
{ Effect = "Allow"
Action = ["kms:GenerateDataKey"]; Resource = aws_kms_key.dest.arn }
]
})
}
# Replication configuration on the source bucket.
resource "aws_s3_bucket_replication_configuration" "replication" {
provider = aws.account_a
role = aws_iam_role.replication.arn
bucket = aws_s3_bucket.source.bucket
depends_on = [aws_s3_bucket_versioning.source]
rule {
id = "replicate-all"
status = "Enabled"
destination {
bucket = aws_s3_bucket.dest.arn
storage_class = "STANDARD_IA"
# Re-encrypt with destination account's KMS key.
encryption_configuration { replica_kms_key_id = aws_kms_key.dest.arn }
# Preserve ACLs during replication.
access_control_translation { owner = "Destination" }
account = var.dest_account_id
}
source_selection_criteria {
sse_kms_encrypted_objects { status = "Enabled" }
}
}
}outputs.tf
output "source_bucket_arn" { value = aws_s3_bucket.source.arn }
output "dest_bucket_arn" { value = aws_s3_bucket.dest.arn }
output "replication_role_arn" { value = aws_iam_role.replication.arn }Workflow Commands
terraform init
terraform apply \
-var="source_account_id=111111111111" \
-var="dest_account_id=222222222222" \
-var="source_profile=prod-admin" \
-var="dest_profile=dr-admin"
# Upload a test object to the source
aws s3 cp test.txt s3://prod-primary-prod-111111111111/ --profile prod-admin
# Verify replication (may take a few minutes)
aws s3 ls s3://dr-vault-prod-222222222222/ --profile dr-admin
# Check replication status of a specific object
aws s3api head-object --bucket prod-primary-prod-111111111111 --key test.txt \
--profile prod-admin --query ReplicationStatus
terraform destroy -var="source_account_id=..." -var="dest_account_id=..." \
-var="source_profile=..." -var="dest_profile=..."Apply complete! Resources: 14 added, 0 changed, 0 destroyed. Outputs: dest_bucket_arn = "arn:aws:s3:::dr-vault-prod-222222222222" replication_role_arn = "arn:aws:iam::111111111111:role/s3-replication-role-prod" source_bucket_arn = "arn:aws:s3:::prod-primary-prod-111111111111"

