An AWS access key stored as a GitHub secret works from anywhere, for anyone who copies it, until somebody remembers to rotate it. GitHub Actions can instead request a signed OIDC token for each job, and AWS STS exchanges it for temporary role credentials only when the token's audience and subject match the role's trust policy. This page is the setup runbook: create the identity provider, write a trust policy that pins the exact repository and branch or environment, update the workflow, prove it works, roll back safely and delete the old keys. It is for platform and DevOps engineers who own AWS accounts and GitHub organisations. For threat modelling and the finer points of claim design across clouds, see the GitHub Actions OIDC Federation Hardening Guide; this page focuses on doing the migration.
Key takeaways
- The trust policy is the security boundary. AWS warns that if
token.actions.githubusercontent.com:sub is not limited to your organisation or repository, workflows outside your control can assume the role. Pin sub to one repository and one branch or environment.
- Check which subject format your repository emits. Repositories created after 15 July 2026, renamed or transferred since, or opted in, use immutable subjects such as
repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main. A policy written for the old name-only format fails with "Not authorized to perform sts:AssumeRoleWithWebIdentity".
- The workflow needs only
id-token: write. That permission lets the job request an OIDC token; it does not grant write access to any repository resource.
- Credentials last an hour by default. The
aws-actions/configure-aws-credentials action requests 3,600 second sessions unless you set role-duration-seconds (900 to 43,200 seconds, within the role's maximum).
- Finish by deleting keys. The migration is not done until the IAM user's access keys are deleted and the GitHub secrets removed.
When you need this
AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY sit in repository, environment or organisation secrets. These are long-lived IAM user keys, the pattern OWASP lists as NHI7 Long-Lived Secrets and NHI6 Insecure Cloud Deployment Configurations.
- Keys have leaked or nearly leaked. In the CISA Private-CISA GitHub leak, a contractor's public repository holding GitHub Actions workflows also held administrative credentials for AWS GovCloud accounts for about six months, with push protection disabled.
- You cannot answer which workflow made an AWS call. With a shared IAM user, every pipeline looks the same in CloudTrail. Federated sessions record the token subject.
- Rotation is manual. Federation removes the rotation task for CI entirely, because nothing persistent is stored.
Before you start
| Item | What you need |
| AWS permissions | Rights to create an IAM OIDC identity provider and IAM roles in the target account (an administrator or a delegated IAM role) |
| GitHub permissions | Write access to the workflow files; admin on the repository if you will create environments and protection rules |
| Issuer URL | https://token.actions.githubusercontent.com on GitHub.com; https://HOSTNAME/_services/token on GitHub Enterprise Server |
| Audience | sts.amazonaws.com, the default of the official action (China regions use sts.amazonaws.com.cn via the audience input) |
| Subject plan | Which repository, and which branch, tag or environment may deploy. An environment-based subject (repo:ORG/REPO:environment:prod) is the usual choice for production |
| Permissions plan | The exact AWS actions and resources the job uses, from CloudTrail history for the old IAM user |
Decide the subject from the job's real context. GitHub builds sub from the workflow context: if the job references an environment, the subject is the environment form; otherwise a pull request produces repo:ORG/REPO:pull_request, and a push produces the branch or tag form. Colons inside values are encoded as %3A.
| Subject form | Example | Use it for | Watch out for |
| Environment | repo:ORG/REPO:environment:prod | Production deployments gated by reviewers and branch rules | Any branch allowed into the environment can assume the role, so the environment rules are part of the trust |
| Branch | repo:ORG/REPO:ref:refs/heads/main | Simple pipelines without environments | Anyone who can push to the branch can deploy; protect the branch |
| Tag | repo:ORG/REPO:ref:refs/tags/v1.0 | Release jobs triggered by tags | Exact tags only with StringEquals; protect tag creation |
| Pull request | repo:ORG/REPO:pull_request | Read-only checks such as a plan | Never trust a deploy role from pull requests |
Step-by-step
- Create the OIDC identity provider once per account.
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
The action's documentation notes that a certificate thumbprint is no longer necessary and is ignored if specified. Check first with aws iam list-open-id-connect-providers; there is one provider per URL per account.
- Write the trust policy. Save as
trust.json, replacing the account ID, organisation, repository and environment:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Federated": "arn:aws:iam::<ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:<ORG>/<REPO>:environment:prod"
}
}
}]
}
For an immutable-subject repository use repo:<ORG>@<ORG_ID>/<REPO>@<REPO_ID>:environment:prod. IAM rejects a GitHub trust policy at save time if the sub key is missing or is only * or ?, but it accepts repo:ORG/*, which trusts every repository in the organisation.
- Create the role and attach least-privilege permissions.
aws iam create-role --role-name gha-payments-api-prod-deploy \
--assume-role-policy-document file://trust.json --max-session-duration 3600
aws iam put-role-policy --role-name gha-payments-api-prod-deploy \
--policy-name deploy --policy-document file://deploy-permissions.json
A role has no permissions by default. Build deploy-permissions.json from what the job actually calls. The action's documentation reports that a role named exactly "GitHubActions" has been reported not to work, so use a descriptive name.
- Protect the environment in GitHub. Create the
prod environment and add deployment branch and tag rules and required reviewers. Both GitHub and AWS recommend protection rules whenever environments appear in OIDC policies.
- Update the workflow.
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: prod
steps:
- uses: actions/checkout@<pinned-sha>
- uses: aws-actions/configure-aws-credentials@<pinned-sha>
with:
role-to-assume: arn:aws:iam::<ACCOUNT_ID>:role/gha-payments-api-prod-deploy
role-session-name: payments-api-${{ github.run_id }}
aws-region: eu-west-2
- run: aws sts get-caller-identity
Pin third-party actions to a full commit SHA, as GitHub's own example does. Leave output-credentials at its default of false so credentials are not exposed as step outputs.
- Run it on a pilot branch or environment first. Keep the old secrets in place but unused by this job until the federated run succeeds.
Verify it worked
- Job output:
aws sts get-caller-identity returns an assumed-role ARN (arn:aws:sts::<ACCOUNT_ID>:assumed-role/gha-payments-api-prod-deploy/payments-api-...), not an IAM user.
- CloudTrail: find the
AssumeRoleWithWebIdentity event (event source sts.amazonaws.com). Its userIdentity.type is WebIdentityUser, responseElements.subjectFromWebIdentityToken holds the GitHub subject, and responseElements.audience the audience. Requests to the global STS endpoint are logged in us-east-1; regional endpoint requests are logged in their own region.
- Negative test: trigger the workflow from a branch or environment the policy does not name. It must fail with "Not authorized to perform sts:AssumeRoleWithWebIdentity".
- Claim inspection: if you are unsure what subject the job emits, AWS's action documentation points to the
actions-oidc-debugger action to print decoded claims, run in a private repository only.
| CloudTrail field | What it tells you |
userIdentity.identityProvider | The IAM OIDC provider ARN that vouched for the caller |
responseElements.subjectFromWebIdentityToken | The repository and ref or environment that obtained credentials |
requestParameters.roleSessionName | Your session name, ideally containing the run ID for correlation with GitHub logs |
requestParameters.durationSeconds | Requested session length |
additionalEventData.identityProviderConnectionVerificationMethod | IAMTrustStore or Thumbprint, how AWS verified the provider |
Rollback and common errors
| Symptom | Likely cause | Fix |
| "Not authorized to perform sts:AssumeRoleWithWebIdentity" | sub mismatch: wrong branch, job uses an environment, or immutable versus legacy format | Inspect claims, then match the policy to the format the repository emits |
| The action cannot obtain an OIDC token | Missing id-token: write at job or workflow level | Add the permission; job-level permissions replace workflow-level ones |
| Audience error | Provider created with a different client ID, or a custom audience input | Make the provider client ID, policy aud and action input agree |
| Session duration rejected | role-duration-seconds above the role's maximum session duration | Lower the input or raise the role maximum deliberately |
| Works for pushes, fails for pull requests | Pull request subjects are repo:ORG/REPO:pull_request | Give pull request jobs a separate, read-only role, or none |
Rollback: during the pilot, revert the workflow change to restore the secret-based login; nothing in AWS needs undoing. After cut-over, rollback means re-issuing keys, which you should avoid. Once the federated workflow has run cleanly through a release, deactivate the old IAM user's keys (aws iam update-access-key --status Inactive), wait a cycle, then delete them and the GitHub secrets. To withdraw federation from a repository, change or delete the role's trust policy; credentials already issued remain valid until they expire, which is why short session durations matter. See How to rotate an AWS IAM access key without downtime for the key retirement steps.
Hardening afterwards
- Opt in to immutable subjects. For repositories created before 15 July 2026, opt in at organisation or repository level so a recycled organisation or repository name cannot match your trust policy.
- Use
StringEquals, not wildcards. List exact subjects. Avoid ForAllValues: operators in Allow statements, which the action's maintainers warn return true when a claim is absent or misspelled.
- One role per purpose. Separate read-only plan roles from deploy roles, and production from non-production, so a compromised feature branch cannot deploy.
- Customise the issuer for enterprises. GitHub Enterprise Cloud can issue tokens from
https://token.actions.githubusercontent.com/<enterpriseSlug>, so AWS trusts only your enterprise's repositories. This requires a new IAM provider for that URL.
- Watch untrusted triggers. The action's security notes call out
pull_request_target and non-ephemeral runners. Dependabot jobs present an event_name of dynamic; GitHub advises allowing only expected event names where the provider supports conditions on it.
- Alert on drift. Alert on
UpdateAssumeRolePolicy for CI roles, on new OIDC providers, and on AssumeRoleWithWebIdentity with an unexpected subject.
| Metric | Target |
| IAM users with access keys used only from CI | Zero |
GitHub trust policies using StringLike on sub | Zero for production roles |
| CI roles with a maximum session duration above one hour | Exceptions documented |
| Trust policy changes without a linked change | Zero |
| Framework | Reference | Relevance |
| MITRE ATT&CK | T1552.001 Credentials In Files; T1078.004 Cloud Accounts | Keys exposed in repositories and workflow files; abuse of valid cloud roles |
| NIST SP 800-53 Rev. 5 | IA-5, IA-5(7), AC-6, AU-6 | Authenticator management, no embedded static authenticators, least privilege, log review |
| NIST CSF 2.0 | PR.AA-01, PR.AA-05, DE.CM-03 | Credential management, least privilege, monitoring |
| OWASP NHI Top 10 2025 | NHI6 Insecure Cloud Deployment Configurations; NHI7 Long-Lived Secrets | Static CI credentials and weak OIDC claim conditions |
Practitioner checklist
- List every workflow that uses AWS keys from secrets, and the IAM users behind them.
- Create the GitHub OIDC provider in each account, with audience
sts.amazonaws.com.
- Create one role per repository and purpose, with
StringEquals on aud and an exact sub.
- Confirm whether each repository emits immutable or legacy subjects, and opt older repositories in.
- Use protected environments for production subjects.
- Grant permissions from observed usage, with a one hour session maximum unless justified.
- Run positive and negative tests and check
subjectFromWebIdentityToken in CloudTrail.
- Deactivate, then delete, the old IAM user keys and GitHub secrets.
Standards and references
Related NHI Mgmt Group resources: GitHub Actions OIDC Federation Hardening Guide · Multi-Cloud Workload Identity Troubleshooting Guide · How to find unused AWS IAM roles and access keys · How to set up workload identity federation for GitLab CI to Azure
NHIMG Editorial Note
Researched and written by the NHI Mgmt Group editorial team using AI tools, under the editorial direction of Lalit Choda. Facts are drawn from the sources listed on this page and checked by our automated quality process. Last updated 28 October 2026. Spotted an error? Use our
contact page.
NHI Mgmt Group, the independent authority on Non-Human Identity, IAM and Agentic AI security.
nhimg.org