GitHub Actions to cdk deploy (OIDC role to CDK bootstrap roles)
The handoff between a GitHub Actions OIDC role and the CDK bootstrap roles — the OIDC role only needs to assume cdk-<qualifier>-* roles (and read the bootstrap version), same-account and cross-account variants, why a missing sts:AssumeRole silently falls back to the CI role's own permissions, the workflow, and how to verify.
markdownpatternv4D5D8-JB1Tstage: deploytrust: officialservice: cdkservice: iamservice: stsinterface: iacinterface: cliGitHub Actions CDK deployOIDC CDKcdk deploy CI role permissionssts:AssumeRole cdk-hnb659fdscross-account CDK pipelinecdk bootstrap --trust CI accountProceeding anyway CIconfigure-aws-credentials cdkleast privilege CDK CI role
GitHub Actions to cdk deploy
The GitHub OIDC role should hold almost no permissions of its own: it only needs to assume the CDK bootstrap roles. The bootstrap roles do the publishing and deploying, and CloudFormation's execution role creates the resources. Set up the OIDC provider and trust policy first (guides/iam/ci-oidc-github.md).
The chain
GitHub job ──AssumeRoleWithWebIdentity──▶ gha-deploy (your OIDC role)
gha-deploy ──AssumeRole──▶ cdk-<q>-file-publishing-role / image-publishing-role (upload assets)
gha-deploy ──AssumeRole──▶ cdk-<q>-lookup-role (context lookups, if any)
gha-deploy ──AssumeRole──▶ cdk-<q>-deploy-role ──PassRole──▶ cdk-<q>-cfn-exec-role ◀── CloudFormation
Same account
The bootstrap roles trust the account ("AWS": "<account-id>"). An account principal in a trust policy delegates the decision to IAM, so the OIDC role's own policy must allow the assume:
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AssumeCdkBootstrapRoles",
"Effect": "Allow",
"Action": ["sts:AssumeRole", "sts:TagSession"],
"Resource": "arn:aws:iam::<account-id>:role/cdk-hnb659fds-*"
}]
}
- Without this policy, nothing fails at first. The CLI warns
… could not be used to assume '<role-arn>', but are for the right account. Proceeding anyway.and tries with the OIDC role's own credentials. If you gave that role broad permissions "to make it work", every resource is now created as the CI role, bypassing your scoped execution policy. Keep the OIDC role narrow, so that this mistake fails loudly. - Don't pass an external ID. The bootstrap roles deny
AssumeRolecalls that carry one (DenyExternalIddefaults totrue). - With a custom qualifier, replace
hnb659fdsin the ARN.
Cross-account (tools account deploys to prod)
- In prod, bootstrap with
--trust <tools-account>(keep any accounts already trusted) and an explicit--cloudformation-execution-policies(guides/cdk/bootstrap-and-deploy.md). - In tools, the OIDC role's policy allows
sts:AssumeRole(andsts:TagSession) onarn:aws:iam::<prod-account>:role/cdk-hnb659fds-*. - The stack's
env.accountis prod. If either half is missing, the CLI stops withCould not assume role in target account …(reference/cdk/errors.md). Unlike same-account, there's no fallback.
Workflow
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # trust policy sub: repo:<org>/<repo>:environment:production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 24 }
- run: npm ci
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::<tools-or-same-account>:role/gha-deploy
aws-region: <region>
role-session-name: gha-${{ github.run_id }}
- run: npx aws-cdk deploy --all --require-approval never
Run --require-approval never only behind something that reviews changes, such as a protected GitHub environment or a separate --method prepare-change-set job.
The workflow above is assembled from the referenced docs and wasn't run here. The action versions for checkout and setup-node are UNVERIFIED; pin the ones your organization approves.
Known-good and known-bad
Known-good:
- OIDC role trust: pinned to the repository and environment.
- OIDC role policy: only
sts:AssumeRole+sts:TagSessiononcdk-<q>-*in the target account. - Target account: bootstrapped with
--trustfor the tools account (or same-account) and a scoped--cloudformation-execution-policies.
| Known-bad | Symptom |
|---|---|
OIDC role has AdministratorAccess but no sts:AssumeRole on cdk-* (same account) | Deploys "work", with Proceeding anyway in the log. Resources are created by the CI role, bypassing the scoped execution policy |
OIDC role is narrow and lacks sts:AssumeRole on cdk-* | Same-account fallback, then failures on S3 asset upload (forbidden: null) or CloudFormation calls |
Target bootstrapped without --trust <tools-account>, or a later bootstrap dropped it | Could not assume role in target account … |
Trust policy uses StringLike repo:<org>/<repo>:* | Works, but any branch or PR in the repo can deploy: a silent security bug |
Workflow lacks id-token: write | The credentials step fails before any CDK call |
Which side is wrong:
| Where it fails | Side |
|---|---|
The configure-aws-credentials step | GitHub ↔ OIDC role: trust policy sub/aud, provider, id-token: write (guides/iam/ci-oidc-github.md) |
cdk deploy before any stack event, with assume or bootstrap messages | OIDC role ↔ bootstrap roles: role policy, --trust, qualifier (reference/cdk/errors.md) |
Stack events (CREATE_FAILED) | Execution role ↔ resources: execution policies, service quotas (guides/cloudformation/fix-failed-stack.md) |
Verify
- The deploy log has no "Proceeding anyway" line.
- In CloudTrail,
sessionContext.sessionIssuer.arnonCreateChangeSet/ExecuteChangeSetiscdk-<q>-deploy-role-<account>-<region>, notgha-deploy. Resource-creating events showcdk-<q>-cfn-exec-role-…withinvokedByset to CloudFormation. To walk back to the GitHub run, follow theAssumeRoleevents to theAssumeRoleWithWebIdentityevent whose session name isgha-<run id>(reference/cloudtrail/event-records.md). - Push from a branch or environment the OIDC trust policy doesn't allow: the credentials step must fail.