The scariest line in any of our old CI configs was a GitHub repository secret named AWS_SECRET_ACCESS_KEY. It was a long-lived IAM user credential with deploy permissions, copied into a dozen repos, never rotated, and exactly the kind of thing that ends up in a breach post-mortem. If that key leaked, an attacker had standing access to our account until someone noticed.

OIDC federation between GitHub Actions and AWS removes that key entirely. Instead of storing static credentials, GitHub mints a short-lived signed token per workflow run, and AWS trades it for temporary STS credentials that expire in an hour. No secrets to store, nothing to rotate, nothing to leak. Here's how to set it up properly, including the trust-policy detail that everyone gets wrong.

How the handshake works

The flow is short:

  1. GitHub Actions requests an OIDC token from its own provider, scoped to the running workflow.
  2. The workflow calls sts:AssumeRoleWithWebIdentity, presenting that token.
  3. AWS validates the token against a registered OIDC identity provider and checks the role's trust policy.
  4. If the claims match, STS returns temporary credentials good for the job's duration.
The whole point is that the credential never exists before the job runs and is useless after it ends. There's nothing static to steal.

Register the identity provider

You register GitHub's OIDC endpoint as an IAM identity provider once per account. In Terraform:

resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["ffffffffffffffffffffffffffffffffffffffff"]
}

(AWS now validates GitHub's certificate against trusted CAs, so the thumbprint is largely vestigial, but the field is still required.)

The trust policy is where security lives

This is the step people botch. A lazy trust policy that allows any repository in your org to assume the role is a privilege-escalation hole, any fork or new repo could deploy to prod. You must constrain the sub claim to the exact repo and ref:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::111122223333: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:my-org/my-repo:ref:refs/heads/main"
      }
    }
  }]
}

Two things to get right here:

  • Pin aud to sts.amazonaws.com with StringEquals, never leave it open.
  • Scope sub as tightly as the workflow allows: a specific branch (ref:refs/heads/main), a tag pattern, or, for deploy gating, an environment (environment:production). Avoid wildcards like repo:my-org/* for anything with write access.

The workflow side

In the workflow you grant the job id-token: write permission (this is what lets it request the OIDC token) and use the official credentials action. No secrets block at all:

name: deploy
on:
  push:
    branches: [main]

permissions:
  id-token: write   # required to mint 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::111122223333:role/github-deploy
          aws-region: us-east-1
      - run: aws sts get-caller-identity   # proves it works
      - run: aws s3 sync ./dist s3://my-app-assets/ --delete

That's the entire change. The repository has zero AWS secrets, and the IAM role's permissions can be scoped to exactly what the deploy needs.

Hardening beyond the basics

  • Use GitHub Environments with required reviewers, and scope the sub to environment:production so prod deploys need approval.
  • Least-privilege the role, the deploy role should grant only the specific S3/ECS/Lambda actions the pipeline uses, not PowerUserAccess.
  • Separate roles per environment so a staging workflow can never touch production resources.
  • Set a short role-duration-seconds (default 1 hour) to minimize the window if a token were ever intercepted.

Takeaways

  • OIDC federation eliminates long-lived AWS keys in CI, GitHub mints a per-run token, STS returns hour-long credentials.
  • The trust policy's sub condition is the real security boundary; pin it to a specific repo and ref/environment, never a wildcard.
  • The workflow only needs id-token: write permission and aws-actions/configure-aws-credentials, no stored secrets.
  • Layer on per-environment roles, least-privilege policies, and GitHub Environment reviewers for production deploys.