JSTGTECH
← Back to blog

Ditch IAM access keys: GitHub Actions OIDC to AWS

4 min read

If your GitHub Actions workflows still authenticate to AWS with a stored AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY pair, you’re carrying a long-lived credential that can leak from a log, a fork’s pull_request_target run, or a compromised dependency — and it keeps working until someone remembers to rotate it. OpenID Connect (OIDC) federation gets rid of that entirely: GitHub mints a short-lived, workflow-scoped identity token, AWS STS trades it for temporary credentials, and there’s nothing sitting in your repo secrets for an attacker to steal. This is the same pattern I use for this site’s own deploy pipeline, so what follows is the working setup, not just the AWS docs paraphrased.

How the trust actually works

  1. GitHub Actions exposes an OIDC provider at https://token.actions.githubusercontent.com. Every workflow run can request a signed JWT from it (via id-token: write permission) with claims describing the repo, branch, and workflow.
  2. You register that provider as an IAM OIDC identity provider in your AWS account, once.
  3. You create an IAM role whose trust policy says “I’ll accept tokens from that provider, but only if the sub claim matches this specific repo and ref.”
  4. The aws-actions/configure-aws-credentials action exchanges the JWT for temporary STS credentials scoped to that role, valid for the run only.

No secret ever leaves GitHub’s control plane. Nothing to rotate, nothing to revoke except the role’s trust policy.

Step 1: create the OIDC provider

Do this once per AWS account (Terraform, since you’re presumably managing the rest of your IAM this way too):

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

That thumbprint is GitHub’s OIDC endpoint CA thumbprint. Gotcha: AWS actually ignores this field for GitHub’s provider now (it validates via the standard TLS CA bundle instead), but the argument is still required by the resource — don’t spend time trying to keep it “current,” it’s a legacy requirement AWS kept for backward compatibility.

Step 2: write a trust policy scoped tighter than you think you need

This is where most setups go wrong. The sub claim format is repo:<org>/<repo>:<qualifier>, and it’s tempting to wildcard it into uselessness:

{
  "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:my-org/jstgtech-web:ref:refs/heads/main"
    }
  }
}

Two things to get right:

  • Always set the aud condition. Without it, any GitHub Actions run anywhere that requests a token for sts.amazonaws.com audience and happens to match your sub pattern can assume the role. aud is cheap insurance and AWS’s own quickstart includes it — don’t skip it because the console wizard makes it feel optional.
  • Scope sub to the exact ref, not just the repo. repo:my-org/my-repo:* lets a PR from a fork-turned-branch, a tag push, or an environment: deployment all assume the same role your production deploy uses. If a workflow only needs to deploy from main, pin sub to repo:my-org/jstgtech-web:ref:refs/heads/main. If you use GitHub Environments for a manual-approval gate, scope to repo:my-org/jstgtech-web:environment:production instead — that ties the AWS role to the same approval gate protecting your environment secrets.

For a PR-only workflow (say, terraform plan on pull requests, no write access), use a separate, more restrictive role with sub matching repo:my-org/jstgtech-web:pull_request and a read-only policy attached — don’t reuse your deploy role’s trust policy with a broader condition “just for now.”

Step 3: use it in the workflow

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-site-deploy
          aws-region: us-east-1

permissions: id-token: write is not optional — it’s what lets the runner request the JWT in the first place, and GitHub defaults this to none at the org or repo level on newer accounts. If you get `Error: Not authorized to perform sts:AssumeRoleWithWebIdentity`, check this before anything else; it’s the single most common cause, ahead of trust-policy typos.

Trade-offs worth knowing before you migrate

  • Session duration is capped by the role, not the token. The GitHub JWT is short-lived by nature, but your role’s MaxSessionDuration still governs how long the assumed credentials last. Keep it at the default (1 hour) or lower for deploy roles — there’s no reason a CI job needs an 8-hour session.
  • Cross-account deploys need per-account providers. The OIDC provider and trust policy live in the AWS account being deployed into, not in some central account. If you deploy to three accounts (dev/stage/prod), you need the provider registered in each, with role names and trust conditions matched to that account’s own audience.
  • Self-hosted runners change the token issuer. If you ever move a workflow to self-hosted runners inside your own VPC, the token still comes from token.actions.githubusercontent.com (GitHub issues it, not the runner), so this setup doesn’t need to change — but it’s worth confirming if you’re debugging a runner migration and OIDC suddenly stops working for an unrelated reason (usually a network path to GitHub’s OIDC endpoint from the runner).
  • You still need least-privilege on the role’s permissions policy. OIDC only fixes how the workflow authenticates, not what it’s allowed to do once authenticated. A perfectly scoped trust policy attached to a role with AdministratorAccess is still one compromised Action away from a bad day — scope the permissions policy to exactly the S3 bucket, CloudFront distribution, or Terraform state path the workflow touches.

Rolling it out without a big-bang cutover

If you’re migrating an existing pipeline off access keys, run both in parallel for one deploy cycle: add the OIDC role, switch configure-aws-credentials to role-to-assume, watch a real deploy succeed, then delete the IAM user and its access keys. Don’t delete the old credentials in the same PR that introduces the new role — if the trust policy’s sub condition is wrong, you want a fallback for the next deploy instead of a broken pipeline and no way back in until you fix IAM by hand.

Related posts