
If your CI pipeline still has a long-lived AWS access key sitting in a GitHub repo secret, it's a standing liability: that key works from anywhere, forever, until someone remembers to rotate it. GitHub Actions can authenticate to AWS without any stored credential at all, using OpenID Connect (OIDC) and sts:AssumeRoleWithWebIdentity. This is the AWS leg of what's sometimes called the keyless-CI trilogy — the same pattern as Cosign's keyless container signing and GCP's Workload Identity Federation, just implemented with AWS's own trust-policy primitives.
1. How OIDC federation replaces access keys
Every GitHub Actions job can request a short-lived, signed JSON Web Token (JWT) from GitHub's own OIDC provider. That token says, in effect, "this is workflow X, running on repo Y, triggered by ref Z, issued by token.actions.githubusercontent.com." AWS IAM can be configured to trust that issuer and, when it sees a token matching specific conditions, hand back temporary credentials via AssumeRoleWithWebIdentity — no stored secret required on either side. The token is minted fresh for each job run and expires in minutes.
This means there's no AWS credential to leak from a compromised dependency, no key to rotate on a schedule, and no secret to accidentally echo into a log.
2. Register GitHub as an OIDC identity provider in IAM
AWS needs to know it can trust tokens issued by GitHub. This is a one-time setup per AWS account.
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1The client-id-list value of sts.amazonaws.com is the audience GitHub's token will be issued for when AWS requests it — this must match what you put in the trust policy later. The thumbprint pins GitHub's TLS certificate chain; AWS's own documentation keeps the current value up to date, and in most SDK and Console flows today AWS validates this automatically rather than requiring you to track it by hand, but the CLI still asks for it explicitly.
If the provider already exists (common in an account with other CI integrations), skip this step — you can only register token.actions.githubusercontent.com once per account.
3. Create an IAM role with a scoped trust policy
This is the step that actually matters for security. The trust policy decides which GitHub repo, branch, or environment is allowed to assume the role — get this wrong and you've built a backdoor any repo in any org could walk through.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}
]
}The sub (subject) claim is where the scoping happens. It encodes the repo and the triggering context — a branch ref, a tag, a pull request, or a named GitHub Environment. Some useful patterns:
repo:my-org/my-repo:ref:refs/heads/main— only themainbranch.repo:my-org/my-repo:ref:refs/tags/*withStringLike— any tag push, for release workflows.repo:my-org/my-repo:environment:production— only jobs running under a GitHub Environment namedproduction, which lets you additionally require manual approval or environment-scoped secrets on the GitHub side.repo:my-org/my-repo:pull_request— any pull request from that repo (be careful: forks typically can't use repo secrets, but this still deserves a narrow IAM policy since PR workflows are the highest-risk trigger).
Never use a bare wildcard like repo:my-org/*:* unless you genuinely mean "every workflow in every repo in this org can assume this role." Scope the sub claim as tightly as the deploy actually needs — one role per repo, or even per repo-plus-environment, is the safer default.
Now create the role and attach a permissions policy (kept separate from the trust policy above — the trust policy controls who can assume the role, the permissions policy controls what they can do once they have):
aws iam create-role \
--role-name github-actions-deploy \
--assume-role-policy-document file://trust-policy.json
aws iam attach-role-policy \
--role-name github-actions-deploy \
--policy-arn arn:aws:iam::111122223333:policy/github-actions-deploy-permissionsBuild that permissions policy around least privilege for the actual deploy — for example, permissions scoped to one S3 bucket and one ECS service, not AdministratorAccess. A compromised workflow file is still bounded by whatever the role can do.
4. Configure the GitHub Actions workflow
On the GitHub side, two things have to be true: the job needs id-token: write permission so it's allowed to request an OIDC token at all, and you use the official aws-actions/configure-aws-credentials action to exchange that token for AWS credentials.
name: deploy
on:
push:
branches: [main]
permissions:
id-token: write # required to request the OIDC JWT
contents: read # default, but be explicit
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy
aws-region: us-east-1
role-session-name: gha-deploy-${{ github.run_id }}
- name: Verify identity
run: aws sts get-caller-identity
- name: Deploy
run: ./deploy.shNo aws-access-key-id or aws-secret-access-key input appears anywhere in this workflow. The action detects the OIDC context automatically, requests the token, calls AssumeRoleWithWebIdentity behind the scenes, and exports temporary credentials as environment variables for the rest of the job.
5. What's happening under the hood (the manual version)
It helps to know what the action is doing, in case you ever need to debug it or replicate the flow without the action — for example in a custom script or a different CI system that also issues OIDC tokens. The equivalent manual call is aws sts assume-role-with-web-identity:
# GitHub sets ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN
# as env vars automatically inside a job that has id-token: write.
JWT=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=sts.amazonaws.com" | jq -r '.value')
aws sts assume-role-with-web-identity \
--role-arn arn:aws:iam::111122223333:role/github-actions-deploy \
--role-session-name gha-deploy \
--web-identity-token "$JWT" \
--duration-seconds 3600The response contains an AccessKeyId, SecretAccessKey, and SessionToken — a standard temporary credential triple, valid for as long as you specified in --duration-seconds (capped by the role's configured maximum session duration). Export those three values and any AWS SDK or CLI call in the rest of the job authenticates normally.
6. Multiple environments, one repo
A common real-world shape is one repo deploying to both a staging and a production AWS account. Don't reuse one role across both — create a role per account (or per environment within an account) and gate each with GitHub Environments, which lets you also require a manual approval before the production job runs:
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
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-staging
aws-region: us-east-1
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::222222222222:role/github-actions-production
aws-region: us-east-1Pair this with a trust policy sub condition of repo:my-org/my-repo:environment:production on the production role, so even a leaked or malicious workflow change can't assume the production role without also going through GitHub's environment protection rules.
Common pitfalls
- Forgetting
id-token: write. Without this permission block (at the workflow or job level), the job has no way to request an OIDC token, andconfigure-aws-credentialsfails immediately with an error about a missing token. - Trust policy
subdoesn't match the actual trigger. Asubscoped toref:refs/heads/mainwon't match a workflow triggered by a tag push or a pull request — those produce a differentsubvalue. Match the condition to how the workflow actually runs. - Wrong or missing
audclaim. If theaudcondition doesn't saysts.amazonaws.com, or you requested the token for a different audience, AWS rejects the exchange even if thesubis correct. - Reusing one broad role everywhere. It's tempting to create a single
github-actions-rolewith wide permissions and point every repo's trust policy at it. That turns a repo-scoping problem into an IAM-policy-scoping problem and makes a single compromised workflow a much bigger blast radius. - Forking repos and pull requests. Workflows triggered by a pull request from a fork run with reduced permissions and, by default, cannot access most repo secrets. Don't assume a PR-triggered deploy job has the same trust as a push to
main— if you must run AWS calls on PRs, scope that role to read-only, non-destructive actions.
Troubleshooting
If configure-aws-credentials fails with something like Not authorized to perform sts:AssumeRoleWithWebIdentity, the trust policy's sub or aud condition doesn't match the token GitHub actually issued. Add a debug step that decodes the JWT (you can base64-decode the middle segment) to see the real claims, and compare them character-for-character against the trust policy — a stray trailing slash or wrong branch name is the usual culprit. If the error instead mentions the identity provider itself, double check the OIDC provider's ARN in the trust policy's Principal.Federated field matches what aws iam list-open-id-connect-providers shows for your account — a copy-pasted ARN from documentation or another account won't work. If credentials are issued but subsequent AWS calls fail with AccessDenied, that's a separate, simpler problem: the trust policy succeeded, but the permissions policy attached to the role doesn't grant the action you're calling.
Where to go next
Once this is working for one repo, the same pattern extends cleanly: GCP's Workload Identity Federation does the equivalent exchange for Google Cloud, and Cosign's keyless signing uses the same GitHub OIDC token to get a short-lived certificate from Sigstore's Fulcio CA instead of a long-lived signing key. All three remove a standing secret from your pipeline in favor of a token that's minted, used, and discarded within the lifetime of a single job run — worth rolling out as a standard across every CI-to-cloud integration you maintain, not just AWS.

Experienced DevOps Engineer proficient in Python, Docker, Kubernetes, CI/CD, and cloud technologies. Specialized in automating processes and building scalable infrastructure for seamless development and deployment.


