AWS Cross-Account IAM: the complete setup guide
How to grant an IAM user or role in one AWS account permission to work in another. Trust policies, external IDs, common errors, and a Terraform example.
Cross-account IAM setup on AWS is one of those tasks where every tutorial online shows a different sequence and half of them omit the step that actually makes it work. The mechanism itself is simple — an IAM role in the target account with a trust policy that permits identities from another account to assume it. Getting each of the four required pieces right, in the right order, is where teams lose an afternoon.
This is the complete flow — every step, every policy JSON, and the specific errors that show up when a step is missed.
When cross-account access is the right choice
Cross-account IAM is the standard AWS pattern in two situations:
- Multiple environments in separate accounts — production, staging, and dev in different AWS accounts, but the CI/CD pipeline runs in a fourth “shared services” account and needs to deploy to all three.
- Third-party access — a monitoring vendor, backup provider, or contractor needs read access to specific resources in a customer’s account without being given IAM credentials in that account.
Cross-account access is not the answer for:
- Federating human logins from an IdP → use AWS IAM Identity Center (formerly SSO) instead.
- Granting a workload in EKS or EC2 access to another AWS account → use IAM Roles for Service Accounts (IRSA) or EC2 instance profiles plus role-chaining, which builds on the pattern below.
The three actors
Every cross-account setup involves three entities. Naming them clearly up front prevents the trust-policy confusion that causes most failed setups.
- Source account — where the IAM user, role, or service that will make the request lives. Called the trusted account.
- Target account — where the resources being accessed live. Called the trusting account.
- The role — an IAM role in the target account. It is what actually gets assumed. The source-account identity presents credentials, calls
sts:AssumeRoleagainst this role’s ARN, and receives temporary credentials scoped to the role.
Credentials never leave the source account. Only temporary session tokens flow from the target account back.
Step 1 — create the role in the target account
Sign in to the target account. Go to IAM → Roles → Create role.
- Trusted entity type: Custom trust policy
- Trust policy JSON: the block in Step 2 below
Give the role a name that makes the purpose obvious — for example CrossAccountDeployFromSharedServices rather than Role1. Future engineers will thank the naming decision.
Step 2 — the trust policy
The trust policy is attached to the role in the target account and answers a single question: who is allowed to assume this role?
For a simple cross-account setup where an IAM user in the source account (say account 111122223333) needs to assume a role in the target account:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111122223333:root"
},
"Action": "sts:AssumeRole"
}
]
}
The Principal of arn:aws:iam::111122223333:root does not mean “the root user” — it means “any identity in this account that has been granted sts:AssumeRole permission on this role via its own IAM policy.” The actual grant happens in Step 4.
To restrict to a specific IAM user or role in the source account rather than the whole account:
"Principal": {
"AWS": "arn:aws:iam::111122223333:user/deploy-bot"
}
Or for a specific role in the source account:
"Principal": {
"AWS": "arn:aws:iam::111122223333:role/GitHubActionsDeployRole"
}
Naming the specific principal is the more secure default. The account-wide :root form is only sensible when the source account is fully controlled by the same team and IAM discipline is strong there.
Step 3 — the permissions policy on the role
Trust policies decide who can assume the role. Permissions policies decide what the role can do once assumed. The two are attached separately.
Attach any managed or inline policy to the role that reflects what the source-account identity should be able to do. For a deploy role that only needs to update ECS services and read from S3:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecs:UpdateService",
"ecs:DescribeServices",
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:ecs:*:*:service/prod-cluster/*",
"arn:aws:s3:::prod-artifacts",
"arn:aws:s3:::prod-artifacts/*"
]
}
]
}
Scope the resource ARNs tightly. Resource: "*" on a cross-account role is a common mistake that turns the role into an unintentional back door.
Step 4 — grant sts:AssumeRole on the source-account identity
This is the step every rushed tutorial skips.
The source-account IAM user or role also needs an IAM policy that explicitly permits calling sts:AssumeRole on the target-account role’s ARN.
Attach this policy to the source-account identity (user or role):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::444455556666:role/CrossAccountDeployFromSharedServices"
}
]
}
Both sides need to say yes: the trust policy on the target role permits it, and the source identity’s policy permits it. Missing either half produces the AccessDenied error described later.
Step 5 — assume the role and use it
From the source account, either as an IAM user or as another role, run:
aws sts assume-role \
--role-arn arn:aws:iam::444455556666:role/CrossAccountDeployFromSharedServices \
--role-session-name deploy-session-2026-10-29 \
--duration-seconds 3600
The response contains temporary AccessKeyId, SecretAccessKey, and SessionToken values. Export them:
export AWS_ACCESS_KEY_ID=<AccessKeyId from response>
export AWS_SECRET_ACCESS_KEY=<SecretAccessKey from response>
export AWS_SESSION_TOKEN=<SessionToken from response>
Subsequent AWS CLI calls now run as the assumed role in the target account. To confirm:
aws sts get-caller-identity
The Arn in the output should be arn:aws:sts::444455556666:assumed-role/CrossAccountDeployFromSharedServices/deploy-session-2026-10-29.
For a smoother workflow, configure a named profile in ~/.aws/config:
[profile deploy-prod]
role_arn = arn:aws:iam::444455556666:role/CrossAccountDeployFromSharedServices
source_profile = shared-services
region = ap-south-1
Then any command with --profile deploy-prod auto-assumes the role, caches the credentials, and refreshes them as they expire. No manual sts assume-role calls.
External ID — when it matters
External ID is a shared secret embedded in the trust policy that the caller must also supply when assuming the role. It exists to prevent the confused deputy problem, which shows up in one specific scenario:
- The role is assumed by a third-party service (a SaaS vendor, an external contractor’s tooling) on behalf of the account owner.
- Without an external ID, an attacker who learned the role ARN could trick the third-party service into assuming the role for the attacker’s benefit.
If the caller is the account owner’s own systems, external ID adds nothing and can be omitted. AWS recommends it specifically for third-party access.
Trust policy with external ID:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::999988887777:root"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "a-long-random-string-only-the-two-parties-know"
}
}
}
]
}
The caller must then include --external-id a-long-random-string-only-the-two-parties-know on aws sts assume-role.
Common failures and the fix for each
AccessDenied: User arn:aws:iam::111122223333:user/deploy-bot is not authorized to perform: sts:AssumeRole
The source-account identity does not have permission to call sts:AssumeRole. Step 4 is missing or the resource ARN in it does not exactly match the target role.
Fix: attach the Step 4 policy to the source identity. Confirm the role ARN matches character-for-character, including account number and role name casing.
AccessDenied when assuming role: not authorized to perform sts:AssumeRole on resource: arn:aws:iam::444455556666:role/…
Step 4 is done, but the target-account role’s trust policy does not allow this principal. Either the trust policy names a different account, or it names a specific user/role that does not match the caller.
Fix: on the target-account role, update the trust policy’s Principal to match the actual source identity, or broaden it to the source account and rely on Step 4 for restriction.
An error occurred (InvalidClientTokenId) when calling the AssumeRole operation
The source credentials being used to make the assume-role call are themselves invalid or expired. Not a cross-account problem — the base credentials do not authenticate.
Fix: run aws sts get-caller-identity on the source credentials first, without --profile. If that fails too, the base credentials are the problem.
The security token included in the request is expired
Session credentials from sts:AssumeRole are short-lived (default 1 hour, configurable up to 12 hours via max-session-duration on the role). Long-running scripts eventually hit this.
Fix: use a named profile in ~/.aws/config (Step 5) — the SDK/CLI auto-refreshes tokens transparently. For scripts that outlive 12 hours, re-assume the role in a loop.
Access denied on the target resource, even though the role was assumed
The trust policy and Step 4 both work — the assume-role call succeeded — but the actual AWS API call (s3:GetObject, ecs:UpdateService, etc.) fails.
Fix: the permissions policy attached to the target-account role is missing the action or the resource. Check the role’s attached policies, not the trust policy.
Terraform example
Full cross-account setup as Terraform, for the target account:
resource "aws_iam_role" "cross_account_deploy" {
name = "CrossAccountDeployFromSharedServices"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::111122223333:role/GitHubActionsDeployRole"
}
Action = "sts:AssumeRole"
}]
})
max_session_duration = 3600
}
resource "aws_iam_role_policy" "deploy_permissions" {
name = "deploy-permissions"
role = aws_iam_role.cross_account_deploy.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"ecs:UpdateService",
"ecs:DescribeServices",
]
Resource = "arn:aws:ecs:*:*:service/prod-cluster/*"
}]
})
}
And the matching policy on the source-account identity:
resource "aws_iam_role_policy" "assume_prod" {
name = "assume-prod-deploy"
role = aws_iam_role.github_actions_deploy.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Resource = "arn:aws:iam::444455556666:role/CrossAccountDeployFromSharedServices"
}]
})
}
Resource-based policies — the other way to grant cross-account access
Everything above uses identity-based cross-account access — a role in the target account, assumed by an identity in the source account, with permissions attached to the role. It works for every AWS service and is the recommended default.
But some AWS services also support resource-based policies attached directly to the resource itself. These grant access without any role being assumed. The main services with resource-based policies:
- S3 — bucket policies
- KMS — key policies
- ECR — repository policies
- SNS / SQS — topic and queue policies
- Lambda — resource-based policies for function invoke
- Secrets Manager / Systems Manager Parameter Store — resource policies
When either approach can solve the problem, resource-based policies have one meaningful advantage: the target-account identity keeps their own credentials — no AssumeRole handshake, no expiring session tokens. A CI job in account B can simply call aws s3 cp and it works.
S3 bucket policy example — cross-account read
Attach this to a bucket named shared-artifacts in account 444455556666. It allows account 111122223333 to read every object:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowSourceAccountRead",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111122223333:root"
},
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::shared-artifacts",
"arn:aws:s3:::shared-artifacts/*"
]
}
]
}
The source-account identity still needs s3:GetObject and s3:ListBucket in ITS own IAM policy — S3 requires permission on both ends. The bucket policy is the “yes from the bucket owner”; the identity policy is the “yes from the source-account admin.”
KMS key policy — cross-account decrypt
To let account B decrypt data encrypted with a KMS key in account A, add this to the key policy in account A:
{
"Sid": "AllowSourceAccountDecrypt",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111122223333:root"
},
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "*"
}
The Resource: "*" here refers to the key itself (a key policy is scoped to the key it’s attached to). The source-account identity separately needs kms:Decrypt in its own IAM policy.
ECR repository policy — cross-account image pull
To let account B pull container images from an ECR repository in account A, attach this to the repository:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowSourceAccountPull",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111122223333:root"
},
"Action": [
"ecr:BatchGetImage",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer"
],
"Resource": "*"
}
]
}
The Resource: "*" in a repository policy refers to the repository itself (repo policies are scoped to the one repo they’re attached to). Separately, the source account also needs ecr:GetAuthorizationToken in its own IAM policy — that API call has no resource, so it can’t be scoped inside the repository policy.
When to pick which approach
| Situation | Use |
|---|---|
| Any service without a resource-based policy (EC2, RDS, EKS API, most services) | Identity-based (AssumeRole) |
| S3 bucket shared with one specific other account, low frequency | Resource-based (bucket policy) |
| S3 bucket shared with many accounts or with fine per-user rules | Identity-based — bucket policies grow unmanageable past 3-4 principals |
| KMS key used by cross-account workloads | Both — key policy for the account grant, identity policy for scope |
| ECR repo pulled by workloads in other accounts | Resource-based (repository policy) — simpler than assuming a role just to pull |
| Anything a third-party vendor accesses | Identity-based with external ID (see earlier section) |
The combined pattern — defense in depth
Both approaches can be stacked. For a sensitive S3 bucket, use BOTH a bucket policy naming the source account AND an assumable role in the target account. Now:
- Casual read access goes through the bucket policy — simple, no role juggling
- Elevated operations (delete, replace policy, change encryption) require assuming the target-account role — which produces a distinct CloudTrail event tied to a session name
Two paths, two audit trails, two levels of scrutiny.
What to audit after setup
Once the flow is working, three things prevent it from becoming a security liability over time:
- CloudTrail on the target account — every
AssumeRolecall is logged. Ship the logs to a central bucket and alert on assumptions from unexpected source accounts or at unexpected times. - Permissions boundaries — attach a permissions boundary to the assumed role that caps what it can ever do, even if a future engineer widens its attached policies.
- Session duration — set
max_session_durationon the role to the shortest window that fits the workload. The 1-hour default is fine for most CI jobs; 12 hours is only for interactive engineering sessions.
Cross-account IAM done right is invisible day-to-day and precisely audited when it needs to be reviewed. Done wrong, it becomes the security ticket nobody wants to open six months later.