Authenticating to AWS from GitHub Actions with OIDC
You have AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY sitting in repository secrets, they have not been rotated since the project started, and you want them gone without breaking the deploy. This is the complete OIDC replacement: one identity provider, one narrowly scoped role, three lines of workflow change.
When to use this pattern
- Your workflow deploys to AWS and currently authenticates with a stored key pair.
- You want credentials that expire in minutes and cannot be replayed from another repository.
- You need an audit trail that names the workflow and commit that assumed each session.
The difference is not incremental. A stored key is a bearer credential: whoever holds the string is the identity, from anywhere, until someone remembers to rotate it. A federated session is a claim that is re-evaluated on every job, against conditions you wrote, and it expires whether or not anyone is paying attention.
Prerequisites
How the exchange works
There is no shared secret anywhere in this flow. GitHub signs a short-lived JSON Web Token describing who is asking, and AWS decides whether that description matches a role it is willing to hand out.
Complete working example
The AWS side is two resources. Create them once per account.
# 1. Register GitHub as an OIDC identity provider (once per AWS account).
aws iam create-open-id-connect-provider \
--url "https://token.actions.githubusercontent.com" \
--client-id-list "sts.amazonaws.com"
# 2. Create the role with a trust policy scoped to ONE repository and ref.
cat > trust.json <<'JSON'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012: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:acme/shop:ref:refs/heads/main"
}
}
}]
}
JSON
aws iam create-role \
--role-name frontend-deploy \
--assume-role-policy-document file://trust.json \
--max-session-duration 3600
# 3. Attach only the permissions the deploy actually needs.
aws iam put-role-policy --role-name frontend-deploy --policy-name s3-cf-deploy \
--policy-document '{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["s3:PutObject","s3:DeleteObject","s3:ListBucket"],
"Resource": ["arn:aws:s3:::shop-assets","arn:aws:s3:::shop-assets/*"] },
{ "Effect": "Allow", "Action": "cloudfront:CreateInvalidation",
"Resource": "arn:aws:cloudfront::123456789012:distribution/E123EXAMPLE" }
]
}'The workflow side is three lines plus the existing deploy steps.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
permissions:
contents: read # drop the default write-all token
id-token: write # allow this job to request an OIDC identity token
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/frontend-deploy
aws-region: eu-west-1
role-session-name: gha-${{ github.run_id }} # shows up in CloudTrail
role-duration-seconds: 900
- run: aws sts get-caller-identity # verification, see below
- run: aws s3 sync ./dist s3://shop-assets --delete
- run: aws cloudfront create-invalidation --distribution-id E123EXAMPLE --paths '/*'Step-by-step walkthrough
The identity provider is account-wide. You register token.actions.githubusercontent.com once. Creating it a second time returns EntityAlreadyExists, which is harmless — check for it before creating in Terraform to keep plans clean.
The sub condition is the entire security boundary. Without it, any repository on GitHub can assume your role, because the identity provider vouches for all of GitHub, not for you. The condition must use StringEquals for exact refs. If you genuinely need several refs, use StringLike with a bounded pattern such as repo:acme/shop:ref:refs/heads/release/* — never repo:acme/* and never a bare *.
permissions: at the top replaces the default token. Declaring any permissions block switches the job from GitHub’s permissive default to exactly what you list, so contents: read plus id-token: write also removes the workflow’s ability to push code or create releases.
role-session-name is your audit trail. It appears in CloudTrail on every API call the session makes, so an unexpected s3:DeleteObject can be traced to a run identifier rather than to a shared robot user.
role-duration-seconds should be minutes, not hours. 900 is the AWS minimum and comfortably covers a sync-and-invalidate deploy. If the role is also assumed by a long migration job, give that job its own role rather than raising the duration for everything.
Verification
# Inside the workflow, immediately after configure-aws-credentials:
aws sts get-caller-identityExpected output — note assumed-role, not user:
{
"UserId": "AROAEXAMPLEID:gha-17420993311",
"Account": "123456789012",
"Arn": "arn:aws:sts::123456789012:assumed-role/frontend-deploy/gha-17420993311"
}Then confirm the old credentials are truly unused before deleting them:
# Last-used timestamp for the legacy key — should stop advancing after the switch.
aws iam get-access-key-last-used --access-key-id AKIAEXAMPLE \
--query 'AccessKeyLastUsed.LastUsedDate'Once that timestamp is older than a full release cycle, delete the key and remove the repository secrets. Leaving them in place “just in case” preserves exactly the exposure you set out to remove — and a rollback plan that depends on a live long-lived key is not a rollback plan, it is the original risk with extra steps.
Common pitfalls
Forgetting id-token: write. The action fails with Credentials could not be loaded because no token was ever requestable. The permission must be on the job (or workflow) that assumes the role, not merely on some other job in the file — a permissions block at workflow level is inherited, but a block declared on a sibling job is not.
A trust policy without the aud condition. Omitting the audience check lets a token minted for a different service be replayed against your role. Always assert both aud and sub.
Assuming pull requests inherit the same subject. A pull_request run’s subject is repo:acme/shop:pull_request, so a policy pinned to refs/heads/main rejects it. That is normally correct — preview deploys should use a separate, weaker role, as described in secrets injection for preview environments. The mistake to avoid is widening the production policy to accept pull_request so that one workflow file can serve both cases: that hands production write permissions to any contributor who can open a pull request.
Reusing one role across repositories. It is tempting to create a single github-deploy role and list several repositories in the trust policy, or to reach for a wildcard subject. Both collapse the boundary the exchange exists to create — a compromise in the least important repository on the list becomes a compromise of everything the role can touch. Roles are free; create one per repository and per privilege level, and let the naming make the blast radius obvious.
Leaving the session duration at the default. configure-aws-credentials will happily request an hour, and the deploy takes ninety seconds. The extra fifty-eight minutes are pure exposure if the credentials leak through a build log or a compromised step. Set role-duration-seconds to the smallest value that completes the job and raise it only when a specific job proves it needs more.
Related
- Pipeline Security & Supply Chain Hardening — the parent guide covering the full trust chain this credential sits in.
- CI/CD Pipeline Architecture & Fundamentals — the section overview.
- Pinning GitHub Actions to Commit SHAs — stops a compromised action from using the credentials you just federated.
- Injecting Vault Secrets into Preview Deploys with OIDC — the same exchange applied to ephemeral environments.