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

URL: https://www.devopsness.com/blog/oidc-federation-github-actions-aws
Author: Kiril Urbonas
Published: 2026-07-03
Updated: 2026-10-12
Categories: Cloud
Tags: AWS, CI/CD, Security, GitHub Actions, OIDC

Summary: 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.

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

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](/blog/keyless-cloud-authentication-ending-static-credentials); 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
$ 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
{
  "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
# .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](/blog/github-actions-secrets-guide).

## 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](/blog/github-actions-recipes).

## Sources

- [aws-actions/configure-aws-credentials README](https://github.com/aws-actions/configure-aws-credentials)

