githubawssecurity

Keyless deploys: GitHub Actions to AWS with OIDC

Set up GitHub Actions OIDC with AWS so deploys use short-lived credentials instead of stored access keys. Trust policy, workflow, and the mistakes to avoid.

By Pierre Sauvignon · · 7 min read

The TigerSoup tiger in a hard hat joining a code pipe to a cloud pipe with a wrench, an old-fashioned key crossed out on the ground

GitHub Actions OIDC with AWS is how you deploy without an access key stored anywhere. The workflow asks GitHub for a signed token that says “this is run 4812 of repo X on branch main”, AWS checks the signature, and hands back credentials that expire in about an hour. There is nothing to paste into a secret, nothing to rotate, and nothing to leak.

This is a practical explainer. It covers why stored keys are a liability, how OIDC replaces them, the four setup steps, a minimal trust policy, a minimal workflow, and the mistakes people make on the first attempt.

The problem with access keys

A stored AWS access key is a password that never expires, sitting in a place a lot of things can read. That is the whole problem.

When you create an IAM user, download its access key, and paste it into a GitHub secret, you have created a credential with three properties you do not want:

You can soften this with rotation, scoped permissions, and secret scanning, and you should do those things anyway. But the better move is to not have the key at all. The new project setup checklist puts keyless deploys in the first hour for exactly that reason.

How OIDC replaces them

With OIDC, GitHub becomes an identity provider that AWS trusts, and each workflow run gets a short-lived identity instead of a shared long-lived secret.

The sequence is:

  1. The workflow runs with id-token: write permission and requests a token from GitHub.
  2. GitHub issues a JSON Web Token signed with its private key. The token carries claims: the repository, the branch or tag, the workflow, the environment, the actor, the run ID.
  3. The workflow sends that token to AWS Security Token Service and asks to assume a role.
  4. AWS verifies the signature against GitHub’s public keys, checks the claims against the role’s trust policy, and if they match, returns temporary credentials.
  5. The workflow uses those credentials to deploy. They expire about an hour later, whether or not anyone remembers them.

The important shift is where the control lives. With a key, the control is “who has the secret”. With OIDC, the control is a policy you can read: which repository, which branch, which environment may assume this role. GitHub never sees your AWS credentials and AWS never sees your GitHub ones. The official walkthrough is in GitHub’s docs on configuring OpenID Connect in Amazon Web Services and AWS’s on creating OpenID Connect identity providers.

Setup in four steps

The setup is: add GitHub as an identity provider in the AWS account, create a role that trusts it, give the role the permissions the deploy needs, and reference the role from the workflow.

1. Add the identity provider

In IAM, create an OpenID Connect provider with:

One provider per AWS account for that URL. If you are using one account per project, each project account gets its own provider, which is tidy, because each project’s trust is then confined to its own account.

2. Create the role

Create an IAM role with a custom trust policy that names the provider and restricts the subject claim. The trust policy is the control, so it gets its own section below.

3. Attach permissions

Give the role only what the deploy needs. A static site deploy needs s3:PutObject, s3:DeleteObject, s3:ListBucket on one bucket and cloudfront:CreateInvalidation on one distribution. It does not need AdministratorAccess, and the first time a deploy role has that attached is usually the last time anyone looks at it.

4. Reference the role from the workflow

The workflow asks for an ID token, calls aws-actions/configure-aws-credentials with the role ARN, and then runs ordinary AWS CLI commands. Example below.

The trust policy

The trust policy decides who may assume the role, and the sub condition is the line that matters. Lock it to one repository and one branch.

{
  "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"
        }
      }
    }
  ]
}

What each condition does:

If you use GitHub environments, the subject becomes repo:your-org/your-repo:environment:production, and you can gate the role behind the environment’s required reviewers. That is a clean way to run the dev and prod split described in why dev and prod should never share an AWS account: one role trusting refs/heads/* in the dev account, another trusting environment:production in the prod account.

Avoid repo:your-org/* or repo:your-org/your-repo:* as a subject. The first lets any repository in the organisation assume the role. The second lets any branch and any pull request do it, which is the leak you were trying to close.

The workflow

The workflow needs the id-token: write permission and one step to exchange the token for credentials. Everything after that is normal.

name: Deploy

on:
  push:
    branches: [main]

permissions:
  id-token: write
  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::123456789012:role/MyProjectDeployRole
          aws-region: us-east-1

      - run: aws s3 sync dist/ s3://my-project-site --delete

Notes:

Once this runs, every merge to main deploys. That is what makes speed of iteration a property of the setup rather than something you have to find time for.

Common mistakes

Most first attempts fail on one of six things: a wildcard subject, a missing permission, a second provider, a mismatched audience, an over-powered role, or a stale thumbprint worry.

MistakeWhat happensFix
sub is repo:org/repo:*Any branch or pull request can deploy to prodLock to ref:refs/heads/main or an environment
id-token: write missing”Unable to get ACTIONS_ID_TOKEN_REQUEST_URL”Add the permission at the workflow or job level
Creating a second OIDC providerIAM refuses: one provider per URL per accountReuse the existing one; reference it by ARN
aud not set to sts.amazonaws.comAssumeRoleWithWebIdentity is deniedAdd the aud condition and match the action’s default audience
Role has AdministratorAccessA compromised workflow can do anything in the accountScope permissions to the deploy’s buckets and distributions
Worrying about the thumbprintTime spent on a non-issueAWS now validates GitHub’s certificate chain directly; the thumbprint is no longer load-bearing

Two more that are less about configuration and more about habit:

Putting a deploy role in a shared account. If the project shares an AWS account with five other projects, a scoped role is still a role inside a shared blast radius. One account per project means the worst a deploy role can do is confined to that project. The reasoning is in why every project should have its own AWS account.

Leaving the old key around. Switching to OIDC and leaving the IAM user’s access key active means you now have two ways in, one of which is the bad one. Delete the key once the OIDC deploy has succeeded.

If you would rather not hand-build the provider, the role, the trust policy, and the workflow, TigerSoup creates all four when it sets up a project: a deploy connection from GitHub to the project’s own AWS account using short-lived access, with production gated behind main.

Closing

GitHub Actions OIDC with AWS removes the stored key entirely: each run proves who it is, gets credentials that expire in about an hour, and is confined by a trust policy you can read in ten lines. Lock the subject to your repository and branch, scope the role to the deploy, and delete the old key.

TigerSoup does this wiring for free on Mac as part of setting up a new project, alongside an isolated AWS account and a private GitHub repository. The result is a project where every push to main deploys, and there was never a key to leak.

Frequently asked questions

What is OIDC in GitHub Actions?

OpenID Connect lets a workflow prove its identity to AWS with a signed token issued by GitHub for that run. AWS swaps it for temporary credentials, so no access key is ever stored in GitHub.

How long do the temporary credentials last?

By default about an hour, and they expire on their own. The session duration is set on the IAM role, within the limits AWS allows for the role.

Can a pull request from a fork assume my deploy role?

Not if the trust policy locks the subject to your repository and your main branch. The subject claim for a fork or a pull request is different, so the role refuses it.