Keyless Deploys to AWS with GitHub Actions OIDC

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. For the incident that pushed us to fix this — and a closer look at scoping the trust policy's sub claim — see OIDC federation for GitHub Actions to AWS.

The problem with long-lived keys #

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.

How OIDC fixes it #

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.

Step 1: Create the OIDC provider in IAM #

You only do this once per AWS account. Add an identity provider with:

Provider URL: https://token.actions.githubusercontent.com

Audience: sts.amazonaws.com

In the console it's IAM, Identity providers, Add provider, OpenID Connect. Or with the CLI:

code

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.

Step 2: Create a scoped IAM role #

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.

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"
},
"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.

Step 3: The workflow #

The last piece is the workflow itself. Two things matter: granting the job permission to request a token, and using the official credentials action.

yaml.yaml

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.

Common pitfalls #

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.

The call we'd make #

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.