The situation
A company runs a data platform in Account A (111111111111). Curated datasets land in a single S3 bucket, datalake-curated-a, and every object is encrypted with SSE-KMS using a customer-managed key in Account A (arn:aws:kms:eu-west-1:111111111111:key/abcd1234-...).
A BI team operates out of Account B (222222222222). They run Athena, SageMaker notebooks, and a Tableau pipeline from an IAM role called BIAnalyticsRole, and they need read-only access to the curated bucket: s3:GetObject and s3:ListBucket, nothing more.
Security wants least privilege on both sides, encryption at rest preserved end-to-end (no decrypt-and-re-upload detour), CloudTrail evidence of every access, and no long-lived access keys. The question is how to wire this so it works, and what each failure looks like when it doesn’t.
What actually matters
Before mapping services to the ask, worth spending a moment on what the shape of a correct answer looks like.
Cross-account access to a KMS-encrypted resource is unusual because it traverses two IAM trust boundaries and two distinct policy instruments (bucket, key) on the resource side. The normal AWS mental model (“IAM policies grant things, resource policies restrict things”) breaks down here: in cross-account, both sides have to say yes, and the KMS side has to say yes twice, once to permit the caller and once to authorise using the key material. That’s a lot of “yes” for one GetObject. The question is less “does this work” and more “when it fails, can we tell which yes was missing,” because the diagnostic trail depends on it.
Ownership and blast radius are the next thing to weigh. The bucket lives in Account A. The key lives in Account A. The caller lives in Account B. If Account A’s security team rotates the key, or tightens the bucket policy, the BI pipeline breaks and the BI team finds out at 9am on a Monday. The contract between the accounts isn’t “BIAnalyticsRole can read curated data forever”; it’s “BIAnalyticsRole, as named right now, may call these actions against these resources, with Account A retaining the unilateral right to withdraw.” The access pattern has a named counterparty on each side, and both sides know who they’re granting to.
Least privilege asks us to be specific about the principal, the actions, and the resources on every lock. That rules out "Principal": {"AWS": "arn:aws:iam::222222222222:root"}, which would authorise anyone in Account B with matching IAM permissions. We want the role ARN, by name. It also rules out "Resource": "*" on the BI role’s identity policy, which would let the role reach any bucket and any key it had a matching resource grant for. The bucket ARN, the object ARN, the key ARN: each named.
Observability is the attribute that makes the architecture survivable once it’s live. There will be days when a BI analyst says “I can’t read the file” and the team has minutes, not hours, to work out why. The CloudTrail shape of each failure has to be distinct; it must be possible to tell “bucket policy didn’t grant” from “key policy didn’t grant” from “identity policy didn’t grant” without reading three sets of JSON in parallel. If the three failures produce the same error in the same trail, the architecture looks identical from the outside and debugging is guesswork.
Finally, there’s the question of what cannot be used. AWS-managed encryption keys have fixed policies that customers cannot edit, which makes them a non-starter for cross-account encryption: there is no statement to add, and no way to add one. Any bucket whose contents need to cross an account boundary must be encrypted with a customer-managed key from the outset. That’s a one-time design decision that the data platform has to have made before this pattern can exist.
What we’ll filter on
- Cross-account. The principal in B must be explicitly permitted by policy surfaces in A.
- Stable access. Access lasts for the lifetime of a running pipeline, not just an interactive session.
- Least privilege. Each policy names the specific principal, action, and resource. No wildcards that grant more than the job needs.
- Auditable. Every access and every failure leaves a distinct CloudTrail signature.
- Customer-managed KMS only. Cross-account with
aws/s3is impossible.
The cross-account access landscape
A cross-account, KMS-encrypted S3 read touches more IAM evaluation paths than almost any other single AWS operation. Walking them in the order the request hits them:
1. Service Control Policies (SCPs). If either account sits under an AWS Organizations OU with SCPs, those SCPs form an outer ceiling on what any principal in the account can do. SCPs only deny or allow-list; they never grant. An SCP blocking kms:Decrypt on restricted-classification resources will stop the BI role dead, regardless of what any lower layer says.
2. IAM identity policy on the BI role in Account B. This gives the caller permission to attempt the action. It must allow s3:GetObject and s3:ListBucket on the Account A bucket and kms:Decrypt on the Account A key. Least privilege means naming the ARNs explicitly, not Resource: "*".
3. Resource-based policies in Account A, two of them. Cross-account IAM is an AND rule: for a principal in B to act on a resource in A, both accounts must grant. In A, that’s the S3 bucket policy on datalake-curated-a naming the BI role and allowing the S3 actions, and the KMS key policy on the customer-managed key naming the same principal and allowing kms:Decrypt and kms:DescribeKey. Both are resource-based policies, both sit in Account A, both must independently grant.
4. Optional: KMS grants. A grant is a separate policy instrument alongside the key policy. Allow-only, per-key, per-grantee; ideal for short-lived or programmatic delegations. Not needed here, but relevant when access is ephemeral.
5. Optional: VPC endpoint policies. If the BI account reaches S3 through a Gateway VPC endpoint, the endpoint policy adds another layer. It’s an additional restriction, never a replacement for the layers above.
6. Permissions boundaries and session policies. If the BI role has a permissions boundary or is assumed with a session policy, either acts as a further ceiling. They can only contract the role’s permissions, never expand them.
The rule: every layer must allow, no layer may deny. A single Deny anywhere trumps every Allow elsewhere.
For this situation, with no SCPs and no VPC endpoint in scope, the three layers that must independently grant are the IAM identity policy on BIAnalyticsRole in B, the bucket policy on datalake-curated-a in A, and the key policy on the customer-managed KMS key in A. Three locks, one door.
Side by side
Filtering KMS access mechanisms against the attributes:
| Option | Cross-account | Stable | Least privilege | Auditable |
|---|---|---|---|---|
aws/s3 managed key |
✗ | ✓ | n/a | ✓ |
| CMK key policy (root principal in B) | ✓ | ✓ | ✗ | ✓ |
| CMK key policy (BI role ARN) | ✓ | ✓ | ✓ | ✓ |
| CMK grant | ✓ | ✗ | ✓ | ✓ |
| IAM Identity Center federation | ✓ | ✓ | ✓ | ✓ |
The aws/s3 key fails cross-account because its key policy is non-editable. A CMK key policy granting to root in B authorises any IAM entity in B with matching identity permissions, broader than least privilege asks for. A CMK grant is allow-only and per-grantee but expires or retires, so it’s the correct tool when access is ephemeral, not when the BI pipeline runs continuously. IAM Identity Center federates humans directly into Account A, which eliminates cross-account KMS entirely; a strong answer for interactive analysts, but it doesn’t fit the continuously-running Tableau pipeline that needs a machine principal. The survivor is a CMK key policy statement that names the BI role ARN: cross-account, stable, narrowly scoped, and logging to both accounts’ CloudTrails.
Three locks, one door
CMK key policies, in depth
Three policies, roughly thirty lines of JSON between them.
Lock 1: IAM identity policy on BIAnalyticsRole (Account B).
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadCuratedLake",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::datalake-curated-a",
"arn:aws:s3:::datalake-curated-a/*"
]
},
{
"Sid": "DecryptWithPlatformKey",
"Effect": "Allow",
"Action": ["kms:Decrypt", "kms:DescribeKey"],
"Resource": "arn:aws:kms:eu-west-1:111111111111:key/abcd1234-..."
}
]
}
Two statements. The first lets the role ask S3 for objects and list the bucket; the second lets it decrypt using the specific key. No "Resource": "*" on either one.
Lock 2. Bucket policy on datalake-curated-a (Account A):
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowBIReadCrossAccount",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::222222222222:role/BIAnalyticsRole"},
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::datalake-curated-a",
"arn:aws:s3:::datalake-curated-a/*"
]
}]
}
The Principal names the BI role explicitly. Rooting the principal on Account B would allow any IAM entity in B that has matching identity permissions, broader than we want.
Lock 3. Key policy on the customer-managed KMS key (Account A):
{
"Sid": "AllowBIRoleDecrypt",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::222222222222:role/BIAnalyticsRole"},
"Action": ["kms:Decrypt", "kms:DescribeKey"],
"Resource": "*"
}
"Resource": "*" inside a key policy means “this key”, the policy is attached to the key, so the resource is implicit.
Two details that ambush people on this. The key must be customer-managed. The AWS-managed aws/s3 key has a fixed key policy that customers cannot edit, so you cannot add a cross-account principal to it. Any bucket whose objects need cross-account sharing must be encrypted with a customer-managed key from the outset. KMS key policies don’t auto-grant to their own account. Unlike S3, where the bucket owner has implicit broad rights, a KMS key policy must explicitly name the account’s root principal before IAM policies in that account can do anything with the key. The console’s default key policy ("Principal": {"AWS": "arn:aws:iam::111111111111:root"}) is what makes IAM in A effective. For Account B to act, the key policy names B’s principal separately and the IAM policy in B must also allow.
A worked trace
One GetObject. Happy path, then each failure.
The BI analyst’s Tableau job assumes BIAnalyticsRole and calls:
GET s3://datalake-curated-a/sales/2026/q1/regions.parquet
Happy path. The SDK signs with the role’s temporary credentials. S3 evaluates bucket policy and identity policy, both pass. Object metadata shows SSE-KMS with the customer-managed key. S3 calls kms:Decrypt under the caller’s identity, the API call carries the BI role’s principal, not S3’s. KMS evaluates the key policy (names this principal for Decrypt? yes) and, because the caller is cross-account, the caller’s IAM policy too (allows Decrypt on this key? yes). KMS unwraps the data key, S3 decrypts the object, plaintext streams. CloudTrail records two distinct events: a GetObject data event in Account A’s S3 trail and a Decrypt management event, cross-account KMS operations log in both accounts’ trails.
Failure A. IAM identity policy in B missing kms:Decrypt. The S3 side passes. S3 calls kms:Decrypt; KMS checks the caller’s IAM policy and finds no grant. Client sees 403 AccessDenied. Evidence lands in Account B’s KMS trail: eventName: Decrypt, errorCode: AccessDenied, principal pointing at the BI role.
Failure B, bucket policy in A doesn’t list the BI role. S3 denies before calling KMS at all. 403 AccessDenied. Evidence lands in Account A’s S3 data event trail: GetObject with errorCode: AccessDenied. No KMS event anywhere, the request never got past S3.
Failure C, key policy in A doesn’t list the BI role. S3 accepts and calls kms:Decrypt; KMS looks at the key policy and finds no statement for Account B. Client sees the same 403 AccessDenied as Failure A. Evidence lands in Account A’s KMS trail: Decrypt with errorCode: AccessDenied.
Failures A and C produce near-identical client errors but different CloudTrail signatures. A is visible in the caller’s KMS trail (B’s); C is visible in the key owner’s KMS trail (A’s). Knowing which trail to read tells you which lock is shut.
Where grants and VPC endpoints fit
Grants are the correct instrument when access is temporary or service-driven. A grant attaches a principal, an operations list, and optional encryption-context constraints to a key; once created, the grantee invokes the operations without the key policy mentioning them. When work ends, the grant is retired or revoked. For a named BI role with stable access, the key policy statement is the correct answer. Grants are the upgrade when access becomes ephemeral or encryption-context scoping enters the picture.
VPC endpoint policies narrow which buckets and principals can traverse a private endpoint. They’re a further restriction, not a grant. IAM, bucket, and key policies still have to allow. A matching bucket-policy condition, aws:SourceVpce, lets the bucket owner in A require that cross-account reads arrive via a known endpoint in B, protecting against leaked BI credentials being reused from outside the VPC.
What’s worth remembering
- Cross-account IAM is an AND rule: the caller’s identity policy and the resource-based policy in the resource’s account must both grant, one side granting is not enough.
- Cross-account plus KMS is a triple AND rule: identity policy in B, bucket policy in A, and key policy in A all have to allow; miss any one and the request fails.
- Customer-managed keys are mandatory for cross-account; the AWS-managed
aws/s3key has a non-editable key policy and cannot carry cross-account principals. - KMS key policies do not auto-grant to their own account, so the default statement naming the account’s root principal is what makes IAM in that account effective.
- Deny beats Allow at every layer, an SCP deny, a bucket-policy deny, a key-policy deny, a VPC endpoint policy deny all stop the request cold.
- Failure modes produce distinct CloudTrail signatures: bucket-policy failure lives in Account A’s S3 data event trail with no KMS event; identity-policy failure on KMS lives in the caller’s KMS trail; key-policy failure lives in the key owner’s KMS trail.
- Grants are for ephemeral or service-initiated KMS access; they are allow-only, per-key, per-grantee, and support encryption-context constraints.
- VPC endpoint policies are an additional restriction, never a substitute for the IAM / bucket / key trio.