DEV Community

CK
CK

Posted on

Mastering AWS Cross-Account Secrets with Terraform & KMS

The Multi-Account Reality Check

In modern cloud architecture, multi-account strategies are the norm. We separate development from production, and often centralized shared services into their own hubs.

A very common scenario is having a central "Security" or "Shared Services" AWS account holding sensitive database credentials in AWS Secrets Manager, which need to be accessed by a Lambda function running in a completely different "Workload" account.

It sounds simple on paper:

Create the Secret in Account A.

Attach a resource policy to the secret allowing Account B to read it.

Give the Lambda in Account B IAM permission to read the secret.

You deploy your Terraform, invoke your Lambda, and... fail. You get an AccessDeniedException or a vague KMS error.

I recently ran into this exact wall. Here is why it happens, and the Terraform pattern to fix it.

The "Gotcha": It’s Rarely IAM, It’s Usually KMS

When debugging cross-account access failures with Secrets Manager (or S3, or SQS), 90% of the time developers focus on IAM policies. But when secrets are involved, you are fighting a two-front war: authentication (IAM) and cryptography (KMS).

By default, when you create a secret in Secrets Manager without specifying encryption settings, AWS encrypts it using the AWS-managed key for the service (alias/aws/secretsmanager).

Here is the trap: You cannot modify the Key Policy of an AWS-managed key. It is designed to only trust principals within the same account.

No matter how wide open you make your IAM policies, the external account will never be allowed to decrypt the payload. The door is unlocked, but the box is welded shut.

The Solution: Customer Managed Keys (CMK)

To enable cross-account access, you must take control of encryption. You need to create a Customer Managed Key (CMK) in KMS and explicitly tell it to trust the external account.

Here is the complete Terraform implementation to securely share secrets across accounts.

Prerequisites

We need the AWS Account ID of the "consumer" account (the one running the Lambda function that needs the secret). Let's define it as a variable:

variable "external_consumer_account_id" {
  description = "The AWS Account ID that needs read access to the secrets."
  type        = string
  # Example: "123456789012"
}
# Helper to get current account ID for policy definitions
data "aws_caller_identity" "current" {}
Enter fullscreen mode Exit fullscreen mode

Step 1: The KMS Key (The Gatekeeper)

This is the most critical step. We create a symmetric KMS key. The Key Policy must allow two things:

The current account's root user to manage the key (otherwise you might lock yourself out).

The external account root user to perform cryptographic operations (Decrypt, Describe).

resource "aws_kms_key" "cross_account_secrets_key" {
  description             = "KMS Key for cross-account RDS credentials"
  deletion_window_in_days = 30
  enable_key_rotation     = true

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "Enable IAM User Permissions for current account"
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
        }
        Action   = "kms:*"
        Resource = "*"
      },
      {
        Sid    = "Allow External Account to Decrypt"
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::${var.external_consumer_account_id}:root"
        }
        # The external account needs to decrypt the secret payload
        Action = [
          "kms:Decrypt",
          "kms:DescribeKey"
        ]
        Resource = "*"
      }
    ]
  })
}

resource "aws_kms_alias" "secrets_key_alias" {
  name          = "alias/cross-account-secrets-key"
  target_key_id = aws_kms_key.cross_account_secrets_key.key_id
}
Enter fullscreen mode Exit fullscreen mode

Step 2: The Secret (Using the Key)

Now we create the secret. The crucial part here is the kms_key_id argument. If you omit this, Terraform will happily use the default key, and cross-account access will break.

resource "aws_secretsmanager_secret" "database_credentials" {
  name        = "prod/rds/read-replica-creds"
  description = "Database credentials accessible by workload accounts"

  # CRITICAL: Force the use of our custom KMS key
  kms_key_id  = aws_kms_key.cross_account_secrets_key.id
}
Enter fullscreen mode Exit fullscreen mode

(Note: I'm only showing the secret container creation here).

Step 3: The Secret Policy (Granting Access)

Finally, we need to tell Secrets Manager itself that the external account is allowed to ask for the value. We attach a resource policy to the secret.

resource "aws_secretsmanager_secret_policy" "database_credentials_policy" {
  secret_arn = aws_secretsmanager_secret.database_credentials.arn

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "AllowExternalRead"
        Effect    = "Allow"
        Principal = { 
          AWS = "arn:aws:iam::${var.external_consumer_account_id}:root" 
        }
        Action    = "secretsmanager:GetSecretValue"
        Resource  = "*"
      }
    ]
  })
}
Enter fullscreen mode Exit fullscreen mode

Summary: The Full Picture

For a successful cross-account secret retrieval, three gates must open simultaneously. If any one of these fails, the request fails:

The KMS Key Policy (in the source account) must trust the destination account to Decrypt.

The Secret Resource Policy (in the source account) must trust the destination account to GetSecretValue.

The IAM Role attached to the Lambda/EC2 (in the destination account) must have permissions to perform both actions on the respective ARNs.

Using default AWS keys is the most common trap in multi-account architectures. By shifting to Customer Managed Keys and handling policy definitions explicitly in Terraform, you ensure secure, repeatable cross-account access.

Top comments (2)

Collapse
 
primetarget profile image
Ethan Anderson

Really clear explanation of the KMS side of this problem, thanks for laying it out. I'd gently push back on relying on root in the key and secret policies—even as an external principal. Using specific roles (with external IDs or conditions) tends to be safer and easier to audit in larger orgs. Curious how you'd adapt this pattern for role-based trust instead of account-root-based trust?

Collapse
 
khaldoun488 profile image
CK

Thanks for your feedback.

It really depends on the level of trust and whether you want to decouple the accounts to avoid breaking changes.

That said, if you prefer to bind it to a specific role for stricter security, here is how to do it in Terraform:

Principal = {
  AWS = "arn:aws:iam::${var.external_consumer_account_id}:role/SpecificLambdaRole"
}
Enter fullscreen mode Exit fullscreen mode