Authenticating to AWS from GitHub Actions with OIDC

You have AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY sitting in repository secrets, they have not been rotated since the project started, and you want them gone without breaking the deploy. This is the complete OIDC replacement: one identity provider, one narrowly scoped role, three lines of workflow change.

When to use this pattern

  • Your workflow deploys to AWS and currently authenticates with a stored key pair.
  • You want credentials that expire in minutes and cannot be replayed from another repository.
  • You need an audit trail that names the workflow and commit that assumed each session.

The difference is not incremental. A stored key is a bearer credential: whoever holds the string is the identity, from anywhere, until someone remembers to rotate it. A federated session is a claim that is re-evaluated on every job, against conditions you wrote, and it expires whether or not anyone is paying attention.

Stored Access Key vs Federated Session Five rows compare the two credential models. Lifetime: until rotated versus fifteen minutes. Readable from: anywhere on the internet versus only a matching workflow identity. Proof: possession of a string versus a signed claim checked per job. Revocation: manual deletion versus automatic expiry. Audit: one shared robot identity versus a named session per run. Stored access key Federated session Lifetime until someone rotates it 15 minutes Usable from anywhere on the internet one repository and ref only Proof of identity possession of a string signed claim, checked per job Revocation manual, after you notice automatic on expiry Audit granularity one shared robot identity named session per run

Prerequisites

How the exchange works

There is no shared secret anywhere in this flow. GitHub signs a short-lived JSON Web Token describing who is asking, and AWS decides whether that description matches a role it is willing to hand out.

What AWS Checks Before Handing Over a Session The runner obtains a signed token containing audience, subject and repository claims. AWS STS verifies the signature against GitHub's published keys, compares the audience and subject against the role's trust policy conditions, and only then issues temporary credentials scoped to the role's permission policy. Workflow job permissions: id-token: write Signed identity token aud: sts.amazonaws.com sub: repo:acme/shop:ref: refs/heads/main exp: now + 5 minutes AWS STS checks 1. signature vs GitHub JWKS 2. issuer is the registered identity provider 3. aud + sub match the role trust policy session 15 min Any mismatch in step 3 is the "Not authorized to perform sts:AssumeRoleWithWebIdentity" error. Nothing here is stored: the token is minted per job and discarded when the job ends.

Complete working example

The AWS side is two resources. Create them once per account.

# 1. Register GitHub as an OIDC identity provider (once per AWS account).
aws iam create-open-id-connect-provider \
  --url "https://token.actions.githubusercontent.com" \
  --client-id-list "sts.amazonaws.com"

# 2. Create the role with a trust policy scoped to ONE repository and ref.
cat > trust.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",
        "token.actions.githubusercontent.com:sub": "repo:acme/shop:ref:refs/heads/main"
      }
    }
  }]
}
JSON

aws iam create-role \
  --role-name frontend-deploy \
  --assume-role-policy-document file://trust.json \
  --max-session-duration 3600

# 3. Attach only the permissions the deploy actually needs.
aws iam put-role-policy --role-name frontend-deploy --policy-name s3-cf-deploy \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [
      { "Effect": "Allow", "Action": ["s3:PutObject","s3:DeleteObject","s3:ListBucket"],
        "Resource": ["arn:aws:s3:::shop-assets","arn:aws:s3:::shop-assets/*"] },
      { "Effect": "Allow", "Action": "cloudfront:CreateInvalidation",
        "Resource": "arn:aws:cloudfront::123456789012:distribution/E123EXAMPLE" }
    ]
  }'

The workflow side is three lines plus the existing deploy steps.

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

permissions:
  contents: read        # drop the default write-all token
  id-token: write       # allow this job to request an OIDC identity token

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/frontend-deploy
          aws-region: eu-west-1
          role-session-name: gha-${{ github.run_id }}   # shows up in CloudTrail
          role-duration-seconds: 900

      - run: aws sts get-caller-identity                # verification, see below
      - run: aws s3 sync ./dist s3://shop-assets --delete
      - run: aws cloudfront create-invalidation --distribution-id E123EXAMPLE --paths '/*'

Step-by-step walkthrough

The identity provider is account-wide. You register token.actions.githubusercontent.com once. Creating it a second time returns EntityAlreadyExists, which is harmless — check for it before creating in Terraform to keep plans clean.

The sub condition is the entire security boundary. Without it, any repository on GitHub can assume your role, because the identity provider vouches for all of GitHub, not for you. The condition must use StringEquals for exact refs. If you genuinely need several refs, use StringLike with a bounded pattern such as repo:acme/shop:ref:refs/heads/release/* — never repo:acme/* and never a bare *.

permissions: at the top replaces the default token. Declaring any permissions block switches the job from GitHub’s permissive default to exactly what you list, so contents: read plus id-token: write also removes the workflow’s ability to push code or create releases.

role-session-name is your audit trail. It appears in CloudTrail on every API call the session makes, so an unexpected s3:DeleteObject can be traced to a run identifier rather than to a shared robot user.

role-duration-seconds should be minutes, not hours. 900 is the AWS minimum and comfortably covers a sync-and-invalidate deploy. If the role is also assumed by a long migration job, give that job its own role rather than raising the duration for everything.

Verification

# Inside the workflow, immediately after configure-aws-credentials:
aws sts get-caller-identity

Expected output — note assumed-role, not user:

{
  "UserId": "AROAEXAMPLEID:gha-17420993311",
  "Account": "123456789012",
  "Arn": "arn:aws:sts::123456789012:assumed-role/frontend-deploy/gha-17420993311"
}

Then confirm the old credentials are truly unused before deleting them:

# Last-used timestamp for the legacy key — should stop advancing after the switch.
aws iam get-access-key-last-used --access-key-id AKIAEXAMPLE \
  --query 'AccessKeyLastUsed.LastUsedDate'

Once that timestamp is older than a full release cycle, delete the key and remove the repository secrets. Leaving them in place “just in case” preserves exactly the exposure you set out to remove — and a rollback plan that depends on a live long-lived key is not a rollback plan, it is the original risk with extra steps.

Cut-Over Order When Removing the Legacy Key Four sequential steps: create role and trust policy, switch the workflow to OIDC while the key still exists, watch the key's last-used timestamp go stale, then delete the key and repository secrets. A note marks the third step as the one teams skip, leaving the original credential in place. 1 · Create role nothing else changes 2 · Switch workflow key still present 3 · Watch last-used one full release cycle 4 · Delete key and repo secrets teams stop here — and the exposure they set out to remove is still live Steps 1 and 2 are reversible; step 4 is the one that actually closes the risk.

Common pitfalls

Forgetting id-token: write. The action fails with Credentials could not be loaded because no token was ever requestable. The permission must be on the job (or workflow) that assumes the role, not merely on some other job in the file — a permissions block at workflow level is inherited, but a block declared on a sibling job is not.

A trust policy without the aud condition. Omitting the audience check lets a token minted for a different service be replayed against your role. Always assert both aud and sub.

Assuming pull requests inherit the same subject. A pull_request run’s subject is repo:acme/shop:pull_request, so a policy pinned to refs/heads/main rejects it. That is normally correct — preview deploys should use a separate, weaker role, as described in secrets injection for preview environments. The mistake to avoid is widening the production policy to accept pull_request so that one workflow file can serve both cases: that hands production write permissions to any contributor who can open a pull request.

Reusing one role across repositories. It is tempting to create a single github-deploy role and list several repositories in the trust policy, or to reach for a wildcard subject. Both collapse the boundary the exchange exists to create — a compromise in the least important repository on the list becomes a compromise of everything the role can touch. Roles are free; create one per repository and per privilege level, and let the naming make the blast radius obvious.

Leaving the session duration at the default. configure-aws-credentials will happily request an hour, and the deploy takes ninety seconds. The extra fifty-eight minutes are pure exposure if the credentials leak through a build log or a compromised step. Set role-duration-seconds to the smallest value that completes the job and raise it only when a specific job proves it needs more.

← Back to Pipeline Security & Supply Chain Hardening