Skip to main content
OIDC Federation for GitHub Actions to AWS: Killing Long-Lived Keys

OIDC Federation for GitHub Actions to AWS: Killing Long-Lived Keys

KU
β€’ 7 min readβ€’Updated β€’25 views

We rotated a leaked AWS access key that a workflow had committed to logs. Switching GitHub Actions to OIDC federation meant no static AWS keys exist to leak in the first place.

The Slack alert came from GitGuardian: an AWS access key had shown up in a public fork's Actions log. It was ours. A workflow had set -x enabled and echoed the environment, dumping AWS_SECRET_ACCESS_KEY into a build log that a contributor's fork made public. We rotated it within the hour, but the real problem wasn't that one key. It was that we had long-lived AWS credentials sitting in GitHub Secrets across 40 repositories, each one a rotation chore and a leak waiting to happen.

OIDC federation removes the credential entirely. GitHub already issues every workflow run a signed identity token. AWS can be taught to trust that token and hand back temporary STS credentials directly. No static key exists, so there's nothing to leak, rotate, or commit by accident.

Why static keys keep leaking

A static access key has two dangerous properties: it doesn't expire, and it's copied into a system you don't fully control. Once it lands in a secret, it fans out. It gets echoed into a debug log, as ours did. It gets pulled into a fork's workflow a contributor tweaked. It gets pasted into a Slack thread while someone reproduces a failure locally. Any of those turns a build credential into a standing key to your account, and because it never expires, a key grabbed in March still works in November.

Then there's the rotation tax. Hygiene says rotate every 90 days; in practice that means generating a new key, updating every repo that uses it, and hoping you didn't miss one before deactivating the old key breaks a Friday deploy. Most teams quietly stop rotating, and the keys sit there, over-permissioned and immortal, usually attached to an IAM user with PowerUserAccess. The broader case for dropping static credentials across clouds is in keyless cloud authentication; this is the AWS-plus-GitHub version.

How the trust actually works

Three moving parts. GitHub's OIDC provider issues a JWT for the running job describing which repository, ref, and environment triggered it. You register that provider as an identity provider in AWS IAM. Then you create a role whose trust policy says "GitHub's OIDC provider may assume me, but only for these specific repos and branches." At runtime the workflow presents its token, STS validates the signature and claims via sts:AssumeRoleWithWebIdentity, and returns temporary credentials, valid for one hour by default with aws-actions/configure-aws-credentials.

Register the provider once per AWS account:

bash.bash
$ aws iam create-open-id-connect-provider \
    --url https://token.actions.githubusercontent.com \
    --client-id-list sts.amazonaws.com

In the console it's IAM, Identity providers, Add provider, OpenID Connect, with the same URL and an audience of sts.amazonaws.com. Older guides tell you to pass --thumbprint-list; that's no longer necessary, and per the configure-aws-credentials docs, a thumbprint you supply is ignored.

The trust policy is where people get burned

Getting this wrong means any GitHub repo on the internet can assume your role. Attach only the deploy permissions the role genuinely needs (not AdministratorAccess), then pin both claims in the trust policy:

json.json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::0123456789: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:devopsness/deployer:ref:refs/heads/main"
      }
    }
  }]
}

The aud condition must equal sts.amazonaws.com, which is what configure-aws-credentials requests by default; pinning it stops a token minted for another audience from working.

The sub condition does the real security work. repo:devopsness/deployer:ref:refs/heads/main means only the main branch of that one repo can assume the role. The dangerous mistake is StringLike with repo:devopsness/*:*, which grants every repo in your org, on every branch, including pull requests from forks. A bare * lets any repository on GitHub assume the role, which is a full account takeover waiting to happen. Use StringEquals and be specific; if you need multiple branches, list them rather than wildcarding.

For deploys gated on GitHub Environments, scope to the environment instead: repo:devopsness/deployer:environment:production. That ties the role to a protected environment with its own approval rules, and it's what we use for production.

The workflow side

One permission block and the AWS credentials action. No secrets:

yaml.yaml
# .github/workflows/deploy.yml
name: deploy
on:
  push:
    branches: [main]

permissions:
  id-token: write   # lets the runner request the OIDC token
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::0123456789:role/gha-deployer
          aws-region: eu-west-1
      - run: aws s3 sync ./dist s3://devopsness-web/

There are zero AWS_ACCESS_KEY_ID references. The action exchanges the OIDC token for STS credentials and exports them to the rest of the job, and they're dead by the time the next job starts.

The pitfalls that break it

Missing id-token: write. By far the most common failure. Without it GitHub won't issue a token, and the action fails with a confusing Error: Credentials could not be loaded before it ever talks to AWS. Add it at the job or workflow level, and remember that any permissions block resets the defaults, so include contents: read if the job checks out code.

A loose sub that works anyway. Setup succeeds whether the match is tight or wide open, so a wildcard ships silently and nobody notices until a security review. Check the condition string, not just whether the deploy ran.

Audience mismatch. If you customize the requested audience in the workflow but leave the trust policy on sts.amazonaws.com (or the reverse), the assume-role call is denied. Keep both sides on sts.amazonaws.com unless you have a deliberate reason.

Long jobs outliving the session. Credentials last an hour by default. A slow Terraform apply can expire mid-run. Set role-duration-seconds explicitly, up to the role's MaxSessionDuration.

Expecting OIDC to remove every secret. It removes cloud keys only. A third-party API token still needs careful handling; see using secrets in GitHub Actions safely.

What we saw after switching

Across the 40 repos, we deleted every AWS access key stored in GitHub Secrets: 40 credentials that no longer exist. Workflow credentials now last an hour at most and are scoped to exactly one role. When a contributor's fork tried to run our deploy workflow, it failed at the assume-role step because a fork's sub claim doesn't match refs/heads/main, which is precisely the behavior you want.

Questions people ask

What does "Credentials could not be loaded" mean with GitHub Actions OIDC?

Almost always that the job has no OIDC token to exchange, because the workflow or job is missing permissions: id-token: write. Add it, keep contents: read if the job checks out code, and rerun. If the token is issued but AssumeRole is denied instead, compare the trust policy's aud and sub conditions with the actual repo, ref, or environment of the run.

Do I still need a thumbprint for the GitHub OIDC provider in AWS?

No. AWS no longer needs a certificate thumbprint for GitHub's well-known OIDC endpoint, and the configure-aws-credentials docs say a supplied thumbprint is ignored. Create the provider with just the URL https://token.actions.githubusercontent.com and the client ID sts.amazonaws.com.

The decision, concretely

  • Deploying from one branch? Pin sub to repo:org/repo:ref:refs/heads/main with StringEquals.
  • Production behind approvals? Pin sub to repo:org/repo:environment:production instead.
  • Jobs that run longer than an hour? Set role-duration-seconds and raise the role's MaxSessionDuration.
  • Still have an access key in GitHub Secrets? Treat it as a finding: migrate the repo, then delete the key in IAM.

The call we'd make

If your CI runs on GitHub Actions and deploys to AWS, there is no good reason left to use static access keys. OIDC federation is a one-time provider per account, a role with a careful trust policy, and a few lines of workflow YAML, and it deletes an entire category of incident: leaked keys, forgotten rotations, over-privileged CI credentials. The one place to slow down is the sub condition. Use StringEquals, pin the exact repo and branch or environment, and never wildcard for convenience, because that single string is the difference between "our main branch" and "the whole internet". For more workflow patterns, see our GitHub Actions recipes.

Sources

Explore topics:AWSCI/CDCloud
React
Share this post
KU

Kiril Urbonas

AI Engineer

553 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.