How to Share a CodePipeline Artefact KMS Key Across Accounts

July 24, 2028 · 14 min read

DevOps Engineer Pro · DOP-C02 · part of The Exam Room

The situation

A platform team runs a single pipeline that builds and ships a service to three targets: dev, staging, and prod, each a separate AWS account under one Organization. The pipeline lives in a fourth account, the tooling account, which owns the pipeline, the build project, and the artefact bucket.

  • CodePipeline in tooling with a source stage (CodeStar Connections to GitHub), a build stage (CodeBuild), and three deploy stages (CloudFormation, one per target account).
  • Artefact store: a single S3 bucket in tooling with default encryption set to SSE-KMS using a customer-managed key.
  • Deploy stages assume a role in each target account that has cloudformation:* on a specific stack plus iam:PassRole on the stack’s execution role.
  • Network: everything lives in one Region; no VPC endpoints in play.

The pipeline runs. Source succeeds. Build succeeds. The first deploy stage fails on the artefact download:

User: arn:aws:sts::222222222222:assumed-role/StagingDeployRole/…
is not authorized to perform: kms:Decrypt on resource: arn:aws:kms:eu-west-1:111111111111:key/abcd-…

The bucket policy allows the role. The role has s3:GetObject on the bucket. The error still names KMS, not S3.

What actually matters

The useful frame is: what does “cross-account access to an encrypted artefact” actually need, end to end?

An S3 object encrypted with a customer-managed KMS key is two resources, not one. Reading it requires permission on the bucket and permission on the key. Most cross-account S3 errors that name kms:Decrypt are the key part failing while the bucket part passes. That’s why the bucket policy looks correct and the deploy still fails.

The access path for a cross-account KMS key runs through three separate policy surfaces, and all three have to allow the operation. The key policy is authoritative, it gates who can use the key at all; an IAM policy in another account cannot grant key access unless the key policy has already opened the door for that account. The IAM policy on the principal in the caller’s account then grants the specific action (kms:Decrypt). A grant is the optional third surface, a temporary delegation, usually created by a service on a principal’s behalf. For a CodePipeline artefact, the key policy plus the IAM policy are what matter.

The second thing worth asking is: which principals need the key? Four of them, and it’s easy to miss the less obvious ones. The pipeline service role needs encrypt on upload between stages. The CodeBuild service role needs encrypt on artefact publish and decrypt if the build reads a previous stage’s artefact. The CloudFormation action role in each deploy account needs decrypt to read the template. The CloudFormation execution role, the role CloudFormation assumes to create resources, needs decrypt if the template references the artefact (for example, a Lambda function code bundle stored in the artefact bucket).

The third is the Organization question. Hard-coding each target account’s ID into the key policy works for three accounts; it doesn’t scale to thirty. Condition keys like aws:PrincipalOrgID can broaden the grant to “any principal in our Organization with the correct IAM permission,” moving the security boundary from the key policy to the IAM policies the Organization itself can enforce via SCPs.

What we’ll filter on

Scoring each piece of the fix against:

  1. Cross-account reach, can a principal in a different account use this at all?
  2. Authoritative grant surface, which policy surface gates access for the key?
  3. Blast radius, how many resources and principals does one change affect?
  4. Auditability, can we answer “who decrypted this object?” from CloudTrail?
  5. Scales with the Organization, does adding a new target account require editing the key policy?

The cross-account KMS landscape

1. Leave the artefact bucket on SSE-S3. No KMS, no cross-account key problem. Loses the customer-managed key story for at-rest encryption and the CloudTrail audit of individual Decrypt calls. Regulatory baselines that require customer-managed keys forbid it. Works; not acceptable here.

2. AWS-managed key (aws/s3). An AWS-managed key is per-account and cannot be shared. A principal in staging cannot use tooling’s aws/s3 key, full stop. Cross-account S3 with SSE-KMS requires a customer-managed key in the artefact owner’s account.

3. Customer-managed key in tooling, key policy enumerates each target account. The artefact bucket is encrypted with a CMK in tooling; the CMK’s key policy has a statement "Principal": {"AWS": ["arn:aws:iam::222222222222:root", "arn:aws:iam::333333333333:root", "arn:aws:iam::444444444444:root"]} allowing kms:Decrypt. Each target account’s IAM policies then grant kms:Decrypt on the key ARN to the specific deploy roles. Works cleanly; adds one line to the key policy per new account.

4. Customer-managed key in tooling, key policy uses aws:PrincipalOrgID. Same CMK, but the key policy condition checks the Organization membership of the caller rather than enumerating account IDs. One line stays constant as new accounts land. Relies on SCPs to contain who inside the Organization can invoke kms:Decrypt.

5. Customer-managed key replicated via multi-Region keys. Useful for cross-Region pipelines, a replica key in each deployment Region keeps the artefact-download latency local and avoids the “primary Region is a hard dependency” story. Doesn’t change the cross-account permission model; each replica still needs its own key policy.

6. Grants instead of key-policy principals. A grant delegates a subset of key operations to a grantee principal with an expiration. Useful for service-driven patterns (EBS volumes, Backup copy jobs) but awkward for a long-lived pipeline because grants are discovered via ListGrants, not visible in the key policy, and easy to lose track of. Not the correct tool here.

Side by side

Option Cross-account reach Authoritative surface Blast radius Auditable Scales with Org
SSE-S3 Bucket policy Narrow Partial (S3 only)
AWS-managed aws/s3 N/A N/A
CMK + enumerated accounts Key policy + IAM Narrow
CMK + aws:PrincipalOrgID Key policy + IAM Org-wide
Multi-Region CMK Key policy per replica Narrow per Region ✓ (with work)
Grants Grant + IAM Per grant ✓ (via ListGrants)

The sustainable pick for a multi-account pipeline is the CMK with aws:PrincipalOrgID once the Organization has more than a handful of target accounts; the explicit-account variant is a fine starting point and mechanically identical.

The four principals and three policy surfaces

tooling account (111111111111) target account (222222222222) CodePipeline role puts artefacts between stages CodeBuild role uploads build output S3 artefact bucket (SSE-KMS) bucket policy: allow cross-account list/get default encryption: arn:aws:kms:…:key/abcd-… Customer-managed KMS key key policy: – root of tooling (admin) – aws:PrincipalOrgID == o-xxx – kms:ViaService condition CFN action role reads template from artefact CFN execution role reads referenced bundles IAM policies on both roles kms:Decrypt on key ARN in tooling encrypt decrypt (cross-account) Three policy surfaces must all allow the operation: 1. Key policy in tooling (authoritative) 2. IAM policy on the calling principal in target 3. Bucket policy for the artefact object itself
Four principals, one KMS key, one bucket. Cross-account decrypt requires the key policy to open the door and the target-account IAM policy to walk through it.

The pick in depth

Key policy in the tooling account. Three statements earn their keep. The first is the administrator statement that every CMK needs, the root of the tooling account with full kms:* so the key can be managed. The second is the usage statement for the tooling account itself: the pipeline and CodeBuild roles get kms:Encrypt, kms:Decrypt, kms:ReEncrypt*, kms:GenerateDataKey*, and kms:DescribeKey. The third is the cross-account usage statement, scoped by aws:PrincipalOrgID:

{
  "Sid": "AllowOrgMembersToDecrypt",
  "Effect": "Allow",
  "Principal": {"AWS": "*"},
  "Action": ["kms:Decrypt", "kms:DescribeKey"],
  "Resource": "*",
  "Condition": {
    "StringEquals": {"aws:PrincipalOrgID": "o-exampleorg"},
    "StringEquals": {"kms:ViaService": "s3.eu-west-1.amazonaws.com"}
  }
}

Principal: "*" sounds alarming. The condition block does the work: only principals inside o-exampleorg, calling through S3 in eu-west-1, can use the action. kms:ViaService narrows the blast radius so a compromised cross-account role can decrypt artefacts through S3 but not arbitrary ciphertext outside that service.

IAM policy on the target-account roles. The CloudFormation action role (which reads the pipeline artefact) and execution role (which reads referenced bundles) both need kms:Decrypt on the key ARN:

{
  "Effect": "Allow",
  "Action": ["kms:Decrypt", "kms:DescribeKey"],
  "Resource": "arn:aws:kms:eu-west-1:111111111111:key/abcd-…"
}

Same ARN, both roles. Missing this on the execution role is the classic “template downloads fine, nested asset fails” case.

Bucket policy on the artefact bucket. Grant s3:GetObject, s3:GetObjectVersion, and s3:ListBucket to principals in the Organization, scoped by the same condition:

{
  "Effect": "Allow",
  "Principal": {"AWS": "*"},
  "Action": ["s3:GetObject", "s3:GetObjectVersion", "s3:ListBucket"],
  "Resource": [
    "arn:aws:s3:::tooling-pipeline-artefacts",
    "arn:aws:s3:::tooling-pipeline-artefacts/*"
  ],
  "Condition": {"StringEquals": {"aws:PrincipalOrgID": "o-exampleorg"}}
}

Without this, the target role hits AccessDenied on S3 before it ever asks KMS to decrypt.

A worked failure trace

StagingDeployRole fails the artefact download. Walking the call:

  1. CloudFormation in staging calls s3:GetObject on arn:aws:s3:::tooling-pipeline-artefacts/commit-abc123.zip. Bucket policy allows it (Org condition satisfied). S3 returns the encrypted object stream and the key ARN.
  2. S3 asks KMS to decrypt the data key using arn:aws:kms:eu-west-1:111111111111:key/abcd-…. The call is made by the CloudFormation role in staging, so KMS evaluates the key policy against that principal.
  3. Key policy evaluation: the AllowOrgMembersToDecrypt statement matches (aws:PrincipalOrgID equals o-exampleorg, kms:ViaService equals s3.eu-west-1.amazonaws.com). Key policy allows.
  4. KMS evaluates the IAM policy on StagingDeployRole. If kms:Decrypt on the key ARN is present, the call succeeds. If missing, the error is not authorized to perform: kms:Decrypt, the error in the scenario.

Adding the IAM statement to the target-account deploy role fixes the immediate failure. The key policy alone is necessary for cross-account, the target account’s IAM policy cannot grant access that the key policy hasn’t opened, but it is not sufficient: the target-account IAM policy still has to grant the action to the specific principal. Both surfaces, both directions.

What’s worth remembering

  1. SSE-KMS object access needs two grants, not one. Bucket policy (or IAM S3 policy) for the S3 action, key policy plus IAM for the KMS action. AccessDenied that names kms:Decrypt means the KMS half is failing.
  2. Cross-account KMS requires the key policy to open the door. An IAM policy in another account cannot grant access to a key unless the key policy already permits that account. This is unique to KMS; most AWS services allow IAM alone on the caller side.
  3. aws:PrincipalOrgID + kms:ViaService is the pattern that scales. PrincipalOrgID limits the key policy to Organization members; kms:ViaService limits the grant to calls made through a specific service (S3, in this case). The combination avoids hard-coding account IDs and avoids granting raw Decrypt for arbitrary ciphertext.
  4. Four principals, not two. Pipeline role, CodeBuild role, CloudFormation action role, CloudFormation execution role. Missing the execution role is the common “template loaded, nested Lambda bundle fails” case.
  5. AWS-managed keys do not work for cross-account S3. Only customer-managed keys can be shared. This is the reason default-encrypted buckets break cross-account pipelines as soon as the artefact owner switches from SSE-S3 to SSE-KMS.
  6. Grants are the wrong hammer for long-lived pipelines. They exist for service-driven patterns (Backup copy jobs, EBS CreateVolume, Nitro Enclaves). For a pipeline, use key policy plus IAM, both visible, both in CloudTrail, both in code.
  7. kms:Decrypt CloudTrail events name the caller. userIdentity.arn shows the target-account role; resources shows the key ARN. Answering “which account touched this artefact?” is a single Athena query against CloudTrail, the payoff for picking CMKs over SSE-S3 in the first place.

The AccessDenied on kms:Decrypt is not a symptom of a broken bucket policy. It is the artefact bucket’s customer-managed KMS key rejecting the cross-account call because the key policy, or the IAM policy in the target account, has left a hole. Line up the three surfaces: key policy allows Organization members through S3, target-account IAM policy grants kms:Decrypt on the key ARN, bucket policy allows S3 read under the same Organization condition. The pipeline deploys cleanly across every account the Organization adds next.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.