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
- The workflow requests an ID token from GitHub.
- The runner presents that JWT to the cloud STS / token API.
- The cloud checks
iss,aud, and usuallysub(org, repo, branch, environment). - 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
permissions: write-allat workflow level. Scopeid-token: writeon the job that needs it.- Trusting
repo:org/repo:*. Every ref, including tags an attacker can push if they have write. - Reusing one role across plan and apply. A leaked plan log plus a write role is worse than a leaked plan log.
- Forgetting state backend permissions on the role (
s3:GetObject,dynamodb:PutItemfor the lock table). - 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
-
sublimited 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)
