Stop stashing long-lived AWS access keys in GitHub secrets and let OIDC hand your workflows short-lived, scoped credentials instead.
Almost every pipeline we inherit has the same skeleton in the closet: a pair of AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY values sitting in GitHub repository secrets, wired to an IAM user that has had PowerUserAccess since 2021. It works, which is exactly why nobody touches it. It's also the single most common way we see AWS credentials leak.
A static access key has two properties that make it dangerous. It doesn't expire, and it's copied into a system you don't fully control.
Once a key lands in a secret, it fans out. It gets echoed into a debug log during a bad build. It gets pulled into a fork's workflow that a contributor tweaked. It gets pasted into a Slack thread while someone reproduces a failure locally. Any one of those turns a build credential into a standing key to your account, and because it never expires, an attacker who grabs it in March can still use it in November.
Then there's the rotation tax. Good hygiene says rotate these every 90 days. In practice, rotation means generating a new key, updating every repo that uses it, and hoping you didn't miss one before you deactivate the old key and break a deploy at 5pm on a Friday. Most teams quietly stop rotating. The keys just sit there, over-permissioned and immortal.
You can read the broader case for dropping static credentials in keyless cloud authentication. This post is the concrete AWS-plus-GitHub version.
OpenID Connect (OIDC) replaces the stored key with a trust relationship. GitHub Actions can act as an OIDC identity provider: when a job runs, GitHub mints a short-lived, cryptographically signed JSON Web Token that describes the run, including which repository, branch, and environment triggered it.
AWS is taught to trust that issuer. You register token.actions.githubusercontent.com as an IAM OIDC identity provider once, then create an IAM role whose trust policy says "allow this to be assumed by GitHub tokens that match these exact claims." At runtime the workflow presents its token, AWS validates the signature and the claims, and sts:AssumeRoleWithWebIdentity hands back temporary credentials that expire in an hour or less.
Nothing is stored. There is no secret to leak, no key to rotate, and the credentials a job receives are already dead by the time the next job starts.
You only do this once per AWS account. Add an identity provider with:
https://token.actions.githubusercontent.comsts.amazonaws.comIn the console it's IAM, Identity providers, Add provider, OpenID Connect. Or with the CLI:
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
Modern AWS handles the certificate thumbprint for this well-known GitHub endpoint automatically, so you no longer need to supply one manually.
Now create the role your workflow will assume, and attach whatever deploy permissions it genuinely needs (not AdministratorAccess). The important part is the trust policy, because that is what decides who is allowed to assume it.
{
"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"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
}
}
}
]
}
Two conditions carry the whole security model here.
The aud (audience) claim must equal sts.amazonaws.com. This is what configure-aws-credentials requests by default, and pinning it stops a token minted for some other audience from working.
The sub (subject) claim is the one that actually scopes trust to your code. GitHub sets sub to a string like repo:your-org/your-repo:ref:refs/heads/main. By matching it, you're saying only the main branch of that exact repository may assume the role.
The critical warning: do not get lazy with sub. A condition of repo:your-org/* lets every repo in your org assume the role. A bare * lets any repository on GitHub assume it, which is a full account takeover waiting to happen. Scope to repo:owner/repo at minimum, and to a specific :ref: or :environment: when you can. If you deploy from tags or protected environments, match those instead, for example repo:your-org/your-repo:environment:production.
The last piece is the workflow itself. Two things matter: granting the job permission to request a token, and using the official credentials action.
name: deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
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::123456789012:role/github-deploy
aws-region: us-east-1
- name: Deploy
run: aws s3 sync ./dist s3://my-deploy-bucket --delete
That permissions: id-token: write block is not optional. Without it, GitHub will not issue an OIDC token to the job, and the action fails before it ever talks to AWS. Note there's no aws-access-key-id anywhere. The action exchanges the token for temporary credentials and exports them to the rest of the job.
Missing id-token: write. By far the most frequent failure. The error looks like the action can't find a token to exchange, because there isn't one. Add the permission at the job or workflow level. Remember that setting any permissions block resets the defaults, so include contents: read if your job also needs to check out code.
Too-broad sub condition. The setup "works" whether your sub match is tight or wide open, so a loose condition ships silently and nobody notices until a security review. Always scope to the specific repo, and to a ref or environment where practical.
Wrong audience. If you customized the requested audience in the workflow but left the trust policy expecting sts.amazonaws.com (or vice versa), the assume-role call is denied. Keep both sides on sts.amazonaws.com unless you have a deliberate reason not to.
Confusing this with secrets. OIDC removes cloud keys, not every secret. If your job still needs a third-party API token, handle that separately and carefully. We covered that in using secrets in GitHub Actions safely.
Turn on OIDC for any repository that deploys to AWS, and treat a long-lived access key in a GitHub secret as a finding, not a convenience. The setup is a one-time OIDC provider, a role with a tightly scoped trust policy, and three lines of workflow YAML. In exchange you delete an entire category of credential-leak incidents and never rotate a deploy key again. The one place to slow down and be deliberate is the sub condition, because that single string is the difference between "our main branch" and "the whole internet." For more patterns like this, see our GitHub Actions recipes.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Explore more articles in this category
Run only the CI jobs a change actually affects using if conditionals, trigger path filters, and per-job path detection in a monorepo.
A prioritized toolkit for cutting CI time: measure the critical path first, then cache, parallelize, run only what changed, and shrink the work itself.
Most GitHub Actions pain comes from the same handful of jobs done wrong. This is the map: the recipes that make pipelines fast, secure, and cheap.
Evergreen posts worth revisiting.