The fastest way to leak cloud credentials is to paste an access key into a CI system's secret store and forget about it. Long-lived keys get copied, never rotated, and outlive the people who created them. With OpenID Connect (OIDC), GitHub Actions can request short-lived credentials from AWS and Azure for each workflow run, and there are no secrets to store at all. This article sets that up for both clouds and builds a Terraform pipeline on top: plan on every pull request, apply after approval on merge.
It builds on the state backends from the previous two articles, but the OIDC setup works with any Terraform layout.
How OIDC federation works
- A workflow job asks GitHub for a signed identity token (a JWT). The token says, among other things, which repository, branch or environment the job is running for.
- The job presents that token to AWS STS or Microsoft Entra ID.
- The cloud checks the signature and compares the token's claims, especially
sub(the subject), with a trust rule you defined. - If they match, the cloud issues credentials that expire within about an hour.
Your security comes down to how tightly you write that trust rule, so take care with the sub conditions below.
Recommended repository layout
.
├── .github/workflows/terraform.yml
├── aws/
│ ├── backend.tf
│ └── main.tf
└── azure/
├── backend.tf
└── main.tf
Part 1: Trust GitHub from AWS
Create the GitHub OIDC provider and a role the pipeline can assume. Run this once from an admin workstation:
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
}
data "aws_iam_policy_document" "github_trust" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
# Only this repository, and only the main branch or pull requests
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = [
"repo:my-org/infra:ref:refs/heads/main",
"repo:my-org/infra:pull_request",
]
}
}
}
resource "aws_iam_role" "github_terraform" {
name = "github-terraform"
assume_role_policy = data.aws_iam_policy_document.github_trust.json
max_session_duration = 3600
}
# Start broad while you learn what your stack needs, then narrow it down
resource "aws_iam_role_policy_attachment" "github_terraform" {
role = aws_iam_role.github_terraform.name
policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess"
}
output "role_arn" {
value = aws_iam_role.github_terraform.arn
}
repo:my-org/* in the sub condition. It would let every repository in your organisation, including a new one anyone could create, assume this role.Recent versions of the AWS provider no longer require thumbprint_list for GitHub, because AWS validates GitHub's certificate chain itself. If you are on an older provider version that complains, upgrade rather than hardcoding a thumbprint.
Part 2: Trust GitHub from Azure
On Azure, the pipeline identity is an app registration with a federated credential for each GitHub context it should trust. The Azure CLI makes this quick:
# 1. App registration and service principal
APP_ID=$(az ad app create --display-name "github-terraform" --query appId -o tsv)
az ad sp create --id "$APP_ID"
# 2. Trust the main branch
az ad app federated-credential create --id "$APP_ID" --parameters '{
"name": "github-main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:my-org/infra:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'
# 3. Trust pull requests (for plan)
az ad app federated-credential create --id "$APP_ID" --parameters '{
"name": "github-pr",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:my-org/infra:pull_request",
"audiences": ["api://AzureADTokenExchange"]
}'
# 4. Permissions: deploy resources, and read/write Terraform state
SUB_ID=$(az account show --query id -o tsv)
az role assignment create --assignee "$APP_ID" --role "Contributor" \
--scope "/subscriptions/$SUB_ID"
az role assignment create --assignee "$APP_ID" --role "Storage Blob Data Contributor" \
--scope "/subscriptions/$SUB_ID/resourceGroups/rg-tfstate-cc/providers/Microsoft.Storage/storageAccounts/<state-account>"
Unlike AWS's StringLike, Azure federated credentials match the subject exactly, which is why you need one credential per branch, environment or pull-request context.
In the Azure backend block, add use_oidc = true next to use_azuread_auth = true so the state backend also signs in with the GitHub token.
Part 3: The pipeline
Store these as repository variables, not secrets. None of them is sensitive on its own:
AWS_ROLE_ARN: the role ARN output from Part 1AZURE_CLIENT_ID,AZURE_TENANT_ID,AZURE_SUBSCRIPTION_ID
# .github/workflows/terraform.yml
name: terraform
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
id-token: write # required to request the OIDC token
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
cloud: [aws, azure]
defaults:
run:
working-directory: ${{ matrix.cloud }}
env:
# Read by the azurerm provider and backend
ARM_USE_OIDC: "true"
ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
steps:
- uses: actions/checkout@v4
- name: AWS credentials (OIDC)
if: matrix.cloud == 'aws'
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ca-central-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "~1.11"
- name: Format check
run: terraform fmt -check -recursive
- name: Init
run: terraform init -input=false
- name: Validate
run: terraform validate
- name: Plan
run: terraform plan -input=false -out=tfplan
- name: Apply
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: terraform apply -input=false tfplan
Points worth calling out:
id-token: writeis what allows the job to request an OIDC token. Without it, both clouds reject the sign-in.- The Azure job doesn't need a login step. The
ARM_*variables let the azurerm provider and backend exchange the GitHub token for an Entra ID token directly. Addazure/login@v2only if later steps also runazcommands. applyuses the savedtfplan, so it applies exactly what was planned in that run and nothing that changed in between.
Part 4: Put a human in front of apply
Auto-applying every merge to production is risky. GitHub environments add an approval gate:
- In the repository settings, create an environment named
productionand add required reviewers. - Keep the existing job for pull requests (plan only), and add a separate
applyjob that runs on pushes tomainand declaresenvironment: production. It runsinit,plan -out=tfplanandapply tfplanitself, because a saved plan isn't shared between jobs unless you upload it as an artifact. - In the trust rules for the identity that applies changes, replace the
repo:my-org/infra:ref:refs/heads/mainsubject withrepo:my-org/infra:environment:production. When a job declares an environment, GitHub puts the environment, not the branch, in the token's subject.
Step 3 is the important one. Once the trust rule names the environment, the apply identity can only be assumed by a job that passed the approval gate. A workflow change on some other branch can't skip it.
Hardening checklist
- Split plan and apply identities. Give the pull-request role read-only permissions (for example
ReadOnlyAccesson AWS,Readeron Azure, plus access to state). Only the environment-gated role can make changes. - Narrow permissions. Replace
PowerUserAccessand subscription-wideContributorwith policies scoped to the services and resource groups the stack actually manages. - Protect the workflow file. Require code review from code owners on
.github/workflows/, so nobody can change the pipeline without review. - Watch the logs. The role sessions appear in AWS CloudTrail and the Entra ID sign-in logs, tagged with the repository and run. Alert on sign-ins from unexpected subjects.
Wrapping up
With OIDC, your Terraform pipeline uses credentials that last an hour, are tied to a specific repository and branch, and never sit in a secret store. Together with the S3 and Azure Storage backends from the earlier articles, you have a complete, production-ready multi-cloud Terraform foundation for AWS and Azure.