GitHub Actions OIDC with AWS: short-lived CI credentials without static access keys

Replace static AWS keys in CI with OIDC to IAM roles: GitHub issuer registration, trust policies on jwt claims, least-privilege roles, and common integration failures.

Autor: Matheus Palma6 Min. Lesezeit
Software engineeringDevOpsSecurityGitHub ActionsAWSCI/CD

A pipeline fails at 2 a.m. because someone rotated an IAM user’s access key—but half the repositories still referenced the old secret name in GitHub Actions. Another team discovers an unused deploy key with AdministratorAccess that predates anyone currently on staff. These incidents share a root cause: long-lived symmetric credentials embedded in CI systems outlive the humans who created them, accumulate across repos, and resist orderly revocation. OpenID Connect (OIDC) federation between GitHub and AWS fixes the credential model: each workflow run obtains short-lived credentials by proving identity and context (org, repo, branch, environment) to IAM—no permanent keys in Secrets Manager for the common case.

This article explains why OIDC beats static keys, how AWS IAM trust policies bind to GitHub’s issuer and subject claims, how to shape least-privilege roles for deploy versus build jobs, and what breaks in real integrations (wrong aud, ref patterns that never match, confusion between GitHub-hosted and self-hosted runners). The patterns come from production migrations I have helped teams execute when tightening deploy pipelines and preparing systems for predictable, auditable access—the same bar I apply when advising on scalable release workflows (About).

Why static keys in CI rot faster than your runbooks

IAM access keys are shared secrets. Their security properties are poor for automation that runs continuously:

  • Duplication: every fork or template copy multiplies the secret; rotation becomes a scavenger hunt across orgs.
  • Blast radius: a single key often grants broader API surface than any single pipeline needs.
  • Weak binding: the key proves “someone who once had this blob”—not “this workflow on main in repository X.”

OIDC shifts the proof to cryptographic assertions: GitHub’s Actions service signs JWTs; AWS trusts GitHub’s OIDC issuer and exchanges those assertions for STS AssumeRoleWithWebIdentity credentials that expire in minutes. Leaks hurt less when credentials are scoped, short-lived, and non-reusable outside the intended trust chain.

OIDC federation at a glance

  1. GitHub registers as an OIDC identity provider in your AWS account (issuer URL, thumbprints).
  2. You create one or more IAM roles whose trust policy allows sts:AssumeRoleWithWebIdentity from that provider only when JWT claims match constraints you define (repository, environment, ref).
  3. The workflow uses aws-actions/configure-aws-credentials (or equivalent) with role-to-assume, no access key secrets.
  4. Each job receives temporary credentials (ASIA…) scoped to the role’s policies and duration.

The mental model: the workflow is the client, GitHub is the token issuer, and IAM is the relying party that maps (issuer, subject, audience) → role session.

AWS: identity provider and trust policy mechanics

Register the GitHub OIDC provider

You add an IAM OIDC provider pointing at GitHub’s issuer (commonly https://token.actions.githubusercontent.com). AWS documents thumbprints for the issuer’s TLS chain; treat this as infrastructure as code so every account and landing zone stays consistent.

Trust policy: sub, aud, and string conditions

GitHub’s JWT includes claims such as:

  • iss: stable issuer URL.
  • sub: a string like repo:ORG/REPO:ref:refs/heads/main or repo:ORG/REPO:environment:production when using environments.
  • aud: defaults to a URL-shaped audience; with AWS federation you typically set this explicitly (see pitfalls).

Your trust policy uses StringEquals / StringLike on these claims. Example shape (illustrative—adjust org/repo patterns):

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

Why aud matters: AWS expects a specific audience when you configure the federation path used by configure-aws-credentials. Mismatch manifests as InvalidIdentityToken—a frequent copy-paste failure when mixing older blog posts with current Action defaults.

Separate roles by blast radius

Production hygiene usually means multiple roles, not one “CI admin” role:

  • Read-only for plans, tests, and Terraform validate that should never mutate data.
  • Deploy with permission to update specific CloudFormation stacks, Lambda aliases, or ECS services—not full IAM or organization APIs unless unavoidable.
  • Release artifacts (ECR push) separated from runtime deploy so a compromised build step cannot silently rewrite unrelated infrastructure.

This mirrors how I structure reviews for teams shipping production-ready pipelines: separate what proves code from what mutates live systems.

GitHub Actions: permissions, environments, and subject strings

Minimal permissions

Workflows need:

permissions:
  id-token: write   # required for OIDC token minting
  contents: read     # typical for checkout

Without id-token: write, OIDC token issuance fails in opaque ways. This trips teams migrating from key-based auth where permissions blocks were never defined.

Repositories, refs, and GitHub Environments

Plain branch filters map cleanly to refs/heads/main. Pull requests from forks are a different threat model: GitHub deliberately restricts secrets and OIDC behavior for fork PRs—do not assume your deploy role trust pattern works for arbitrary external contributors.

GitHub Environments (with protection rules and optional reviewers) change the sub claim to include environment:NAME, which is excellent for production roles: you bind IAM trust to repo:ORG/REPO:environment:production instead of a branch name alone—aligning approval gates with AWS trust.

Practical example: deploy role + workflow snippet

Assume account 111111111111, role name gha-deploy-my-service, region eu-west-1. IAM policy attachments allow updating a named CloudFormation stack and publishing versions—details omitted for brevity.

Workflow fragment:

name: deploy

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

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::111111111111:role/gha-deploy-my-service
          aws-region: eu-west-1

      - name: Deploy stack
        run: aws cloudformation deploy --stack-name my-service-prod --template-file packaged.yaml --capabilities CAPABILITY_IAM

On the AWS side, the trust policy’s sub condition aligns with either environment:production or ref:refs/heads/main—pick one coherent strategy per role so operators can reason about access during incidents.

For Terraform or CDK-driven stacks, the same role assumption precedes terraform apply only after plan review—often split across jobs with different roles for plan (read) versus apply (write).

Common mistakes and pitfalls

  1. Missing id-token: write — OIDC never reaches AWS; debugging begins at job permissions, not IAM.
  2. Wrong aud / configure-aws-credentials version drift — validate against current AWS + Action docs when templates age past a year.
  3. Over-broad StringLike on subrepo:my-org/* style patterns are easy to mis-specify; typos silently deny access or, worse, you widen until “it works.” Prefer environments or explicit repo lists generated from IaC.
  4. Using deploy roles on PR workflows from forks — security controls differ; treat fork builds as untrusted inputs.
  5. Ignoring session duration and chaining — keep sessions short; avoid naively nesting multiple role assumptions without mapping trust through single-hop designs teams can trace.
  6. Self-hosted runners — the OIDC token still comes from GitHub, but network egress and runner integrity become your operational surface; threat model accordingly.

None of these are hypothetical—they are the recurring checklist items when hardening CI for teams that need clear audit trails and bounded blast radius.

Conclusion

Replacing static AWS keys in GitHub Actions with OIDC federation is one of the highest-leverage security upgrades a backend team can make: credentials become short-lived, context-bound, and easier to reason about in IAM alone—without secret rotation theater across dozens of repos. The implementation is mostly discipline in trust policies and workflow permissions, not exotic cryptography. Invest up front in role splitting, environment-based subjects, and IaC-managed OIDC providers so every new service inherits the same bar.

If you are designing CI/CD hardening, cloud landing zones, or production deploy pipelines and want a second pair of eyes on trust boundaries, Contact is the best place to reach out.

Newsletter abonnieren

E-Mail erhalten, wenn neue Artikel erscheinen. Kein Spam — nur neue Beiträge von diesem Blog.

Über Resend. Abmeldung in jeder E-Mail möglich.