GitHub Actions #7 AWS Deploys and OIDC: Credentials Without Access Keys
We built an image in the last part, so now it is time to deploy to AWS — but first there is a gate to clear: credentials. The old way was to issue an IAM user’s access key and put it in secrets. It works, but the structure means a powerful key with no expiration is permanently stored in GitHub — the damage is severe if it leaks, and humans forget periodic rotation. The current standard is OIDC integration, which stores no key at all, and Terraform in Practice #7 already finished the AWS-side preparation. In this part we complete the workflow side and connect the two.
How it works: the 90-second version #
The OIDC flow is a single token exchange.
1. A workflow run starts
2. GitHub issues an identity token (OIDC token) for this run
: a signed statement that "this token belongs to a run of the main branch of the my-org/myapp repository"
3. The workflow presents the token to AWS STS
4. AWS verifies the signature and checks it against the trust policy's conditions
5. If the conditions match, AWS issues short-lived credentials (an hour or so)
6. The workflow does its AWS work with those credentials; when they expire, that's itThe key point is that no secret is ever stored. The token is freshly issued for every run and short-lived, and AWS hands out credentials only to “a run signed by GitHub, from our repository, matching the specified conditions.” No key to leak means no key to rotate.
The AWS side: trust configuration recap #
The AWS side needs two things, both built as code in Terraform in Practice #7: registering GitHub’s OIDC provider, and an IAM role with conditions on its trust policy. Let’s revisit just the core of the condition.
"token.actions.githubusercontent.com:sub": "repo:my-org/myapp:*"The sub claim is the field carrying the token’s origin (repository, branch, environment), and this condition is the lock that lets only runs from our repository assume the role. You can tighten it further: repo:my-org/myapp:ref:refs/heads/main allows only main-branch runs, and repo:my-org/myapp:environment:production allows only runs of a specific environment. Combine it with the environment protection rules from #3 and you get a double lock: “only production jobs that passed approval assume this role.”
The workflow side: two pieces #
All the workflow needs is a permission declaration and one action.
permissions:
id-token: write # allow OIDC token issuance
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.DEPLOY_ROLE_ARN }}
aws-region: ap-northeast-2
- run: aws s3 sync ./dist "s3://${{ vars.SITE_BUCKET }}" --delete
- run: >
aws cloudfront create-invalidation
--distribution-id "${{ vars.CF_DIST_ID }}" --paths "/*"id-token: write under permissions is the switch that allows this job to be issued an OIDC token. Without the declaration, token issuance itself is refused — so when OIDC fails complaining about permissions, this is the first line to check. The configure-aws-credentials action performs the entire token exchange flow described above, so the AWS CLI in later steps runs already logged in. The role ARN is not a secret, so by the criteria from #3 it goes in vars.
The example deploys a static site (S3 sync + CloudFront invalidation). If you were instead rolling last part’s image out to ECS, only the last two steps would change — a task definition update and a service update — while the credential structure stays exactly the same. The plan/apply workflow in Terraform Operations #1 sits on the same structure too, so this one integration unifies credentials across the entire deploy and infrastructure pipeline.
Two failures you will run into #
- “Not authorized to perform sts:AssumeRoleWithWebIdentity”: most of the time, the trust policy’s sub condition does not match the actual run’s origin. You restricted by branch but ran from a different branch, or you set an environment condition but the job specifies no environment. The tighter the condition, the more precisely this match has to line up.
- Token issuance refused: a missing
id-token: write. In particular, declaring permissions at the workflow level resets all the default permissions, so you must also list the other permissions (contents: read and so on) or checkout breaks.
Recap #
What we covered in this post:
- Storing access keys means permanently keeping a key that never expires — a structural risk. OIDC exchanges a short-lived token on every run and eliminates stored secrets
- The sub condition in the AWS trust policy is the core of the lock, and it can be tightened down to branch and environment granularity
- The workflow side is two pieces: the id-token write permission and the configure-aws-credentials action
- An AssumeRole refusal usually means a sub condition mismatch; a token issuance refusal means the id-token permission is missing
- Static sites, ECS, and Terraform — every AWS pipeline shares the same credential structure
In the next post (#8 Self-Hosted Runners), we cover the situations GitHub-hosted runners cannot handle: running runners yourself for internal network access, cost, and special hardware.