IAM role assumption across AWS accounts: the right way
Most teams still store long-lived AWS access keys in CI/CD secrets. Here's the right pattern.
Why role assumption beats stored credentials
| Approach | Risk | Rotation | Auditability |
|---|---|---|---|
| Access key in CI secret | High (never expires) | Manual | Poor |
| OIDC + role assumption | Low (per-job token) | Automatic | Full CloudTrail |
Architecture
GitHub Actions → OIDC JWT → IAM (TOOLING account)
→ sts:AssumeRole → ci-deploy-role (PROD account)
→ Deploy
OIDC provider + trust policy
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
resource "aws_iam_role" "github_actions" {
name = "github-actions-oidc"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = aws_iam_openid_connect_provider.github.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringLike = { "token.actions.githubusercontent.com:sub" = "repo:myorg/*:ref:refs/heads/main" }
StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" }
}
}]
})
}
Cross-account role (target account)
resource "aws_iam_role" "ci_deploy" {
name = "ci-deploy-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { AWS = "arn:aws:iam::111111111111:role/github-actions-oidc" }
Action = "sts:AssumeRole"
Condition = { StringEquals = { "sts:ExternalId" = "myorg-prod-deploy" } }
}]
})
}
GitHub Actions workflow
jobs:
deploy:
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111111111111:role/github-actions-oidc
aws-region: us-east-1
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::222222222222:role/ci-deploy-role
role-chaining: true
aws-region: us-east-1
Common mistakes
-
Forgetting
role-chaining: truewhen chaining two AssumeRole calls - Wildcard sub condition — scope per repo for prod
- No ExternalId — prevents confused deputy attacks
Step2Dev automates this pattern for every project.
Top comments (0)