Home / Blog / Infrastructure as Code

Keyless Terraform CI/CD for AWS and Azure with GitHub Actions OIDC

Stop storing cloud keys in CI. Use OpenID Connect federation so GitHub Actions gets short-lived AWS and Azure credentials, then build a Terraform pipeline that plans on pull requests and applies after approval.

TerraformAWSAzureGitHub ActionsOIDC

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

  1. 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.
  2. The job presents that token to AWS STS or Microsoft Entra ID.
  3. The cloud checks the signature and compares the token's claims, especially sub (the subject), with a trust rule you defined.
  4. 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.

.
├── .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
}
Never use a wildcard like 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 1
  • AZURE_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: write is 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. Add azure/login@v2 only if later steps also run az commands.
  • apply uses the saved tfplan, 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:

  1. In the repository settings, create an environment named production and add required reviewers.
  2. Keep the existing job for pull requests (plan only), and add a separate apply job that runs on pushes to main and declares environment: production. It runs init, plan -out=tfplan and apply tfplan itself, because a saved plan isn't shared between jobs unless you upload it as an artifact.
  3. In the trust rules for the identity that applies changes, replace the repo:my-org/infra:ref:refs/heads/main subject with repo: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 ReadOnlyAccess on AWS, Reader on Azure, plus access to state). Only the environment-gated role can make changes.
  • Narrow permissions. Replace PowerUserAccess and subscription-wide Contributor with 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.

Bassel Aasi
Bassel Aasi

Cloud engineer and PMP, certified as an Azure Solutions Architect Expert, AWS SysOps Administrator and Terraform Associate. I help businesses build secure, automated infrastructure on AWS and Azure.

Let's talk about your infrastructure

Tell me where you are and where you want to be. I'll reply within 24 hours with practical next steps.