CI/CD

GitHub Actions OIDC for Terraform: Apply Without Long-Lived Cloud Keys

Wire GitHub’s OIDC token to AWS, GCP, or Azure so Terraform CI can plan and apply without storing static access keys in repository secrets.

2026-08-25 · 4 min read

Static AWS_ACCESS_KEY_ID in GitHub Secrets is still common. It is also a permanent credential that survives rotation policies, leaked workflow logs, and leftover IAM users named github-ci.

OIDC federation gives each workflow run a short-lived token. The cloud trusts GitHub’s issuer, not a password you copied once in 2023.

The trust model

  1. The workflow requests an ID token from GitHub.
  2. The runner presents that JWT to the cloud STS / token API.
  3. The cloud checks iss, aud, and usually sub (org, repo, branch, environment).
  4. You get a 15–60 minute session. Then it expires.

If sub is only repo:acme/*:ref:refs/heads/*, every fork-enabled workflow on every branch can request the role. Tighten it.

AWS

Create an OIDC provider for token.actions.githubusercontent.com once per account. Then a role:

data "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"
}

data "aws_iam_policy_document" "gha_trust" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [data.aws_iam_openid_connect_provider.github.arn]
    }
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values = [
        "repo:acme/infra:environment:prod",
        "repo:acme/infra:ref:refs/heads/main",
      ]
    }
  }
}

resource "aws_iam_role" "gha_terraform" {
  name               = "gha-terraform-prod"
  assume_role_policy = data.aws_iam_policy_document.gha_trust.json
}

Prefer environment:prod in sub and protect that GitHub Environment with required reviewers. Branch-only sub is weaker: anyone who can push to main can apply.

Workflow:

permissions:
  id-token: write
  contents: read

jobs:
  apply:
    runs-on: ubuntu-latest
    environment: prod
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-terraform-prod
          aws-region: us-east-1
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.9.8
      - run: terraform init
      - run: terraform plan -out=tfplan
      - run: terraform apply tfplan

id-token: write is required. Without it the JWT step fails closed.

GCP

Workload Identity Federation binds a GitHub sub to a service account.

- uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: projects/123/locations/global/workloadIdentityPools/github/providers/gha
    service_account: terraform-prod@acme-prod.iam.gserviceaccount.com

Map the attribute attribute.repository = acme/infra and attribute.environment = prod in the provider. Do not use attribute.repository_owner alone.

Azure

Create a federated credential on an App Registration:

  • Issuer: https://token.actions.githubusercontent.com
  • Subject: repo:acme/infra:environment:prod
  • Audience: api://AzureADTokenExchange
- uses: azure/login@v2
  with:
    client-id: ${{ vars.AZURE_CLIENT_ID }}
    tenant-id: ${{ vars.AZURE_TENANT_ID }}
    subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

No client-secret. If you still have a secret on that app, delete it after federation works.

Split plan and apply identities

A plan role should be read-mostly (plus state lock/unlock). An apply role gets write. Bind apply to the GitHub Environment that has reviewers.

pull_request  → role/gha-terraform-plan   (no apply)
main + env    → role/gha-terraform-apply

That stops a malicious PR workflow from applying even if someone adds a rogue terraform apply step. pull_request from forks should not get id-token to your prod role at all — GitHub withholds secrets from fork PRs; OIDC sub for pull_request is also a different shape. Do not add it to the prod trust policy.

Common pitfalls

  1. permissions: write-all at workflow level. Scope id-token: write on the job that needs it.
  2. Trusting repo:org/repo:*. Every ref, including tags an attacker can push if they have write.
  3. Reusing one role across plan and apply. A leaked plan log plus a write role is worse than a leaked plan log.
  4. Forgetting state backend permissions on the role (s3:GetObject, dynamodb:PutItem for the lock table).
  5. OIDC provider thumbprint rot (AWS). Recreate the provider if AssumeRoleWithWebIdentity starts failing after a GitHub cert change.

Checklist

  • No static cloud keys in GitHub Secrets
  • sub limited to environment or a single protected branch
  • Separate plan vs apply roles
  • Apply job uses a reviewed GitHub Environment
  • Plan file is the apply artefact (details)

Platform evaluation

See OrchesTerra on your infrastructure

Request access to generate architecture from a repository, review the plan, and run governed reconciliation across AWS, Azure, GCP, and OCI.