Blog Post

Azure Infrastructure Blog
6 MIN READ

Modernizing Terraform Pipelines on Azure: OIDC Federation for GitHub Actions and Azure DevOps

ssinghkalra's avatar
ssinghkalra
Icon for Microsoft rankMicrosoft
May 02, 2026

The secret nobody wants to rotate

Most Terraform-on-Azure pipelines we see still authenticate the same way they did three years ago. A long-lived ARM_CLIENT_SECRET sitting in GitHub Actions or Azure DevOps, set once, copied around, and rotated only when something breaks.

It's the most ignored credential in the cloud, and statistically the most likely one to leak. A developer screenshots a variable group. A pipeline log echoes a value. A fork inherits a secret. Or the secret simply expires on a Friday evening and takes production deployments with it.

Workload Identity Federation (WIF) makes this whole class of problem go away. The pipeline mints a short-lived token at runtime, exchanges it for an Azure access token via Microsoft Entra, and never touches a secret. GitHub Actions has supported it since 2021. Azure DevOps service connections went GA with WIF in February 2024. The azurerm Terraform provider has supported it since v3.7.

This post walks through the pattern end-to-end, for both GitHub Actions and Azure DevOps, the way I've rolled it out across multiple customer estates.

How the exchange actually works

Before any YAML, it helps to picture what's happening:

  1. The CI system (GitHub or ADO) signs a short-lived JWT describing exactly what's running- which repo, which branch, which environment, which service connection.
  2. The pipeline sends that JWT to Microsoft Entra ID.
  3. Entra checks it against a federated identity credential you've configured on a managed identity or app registration. The iss, sub, and aud claims must match case-sensitively.
  4. If it matches, Entra returns an Azure access token valid for the duration of the job.
  5. Terraform uses it. The job ends. The token expires. Nothing persists.

The token is bound to a specific subject like repo:contoso/platform:environment:prod or sc://contoso/platform/azure-prod. It can't be reused from another repo, branch, or pipeline.

Recommended Architecture

A few choices that usually hold up in production:

DecisionChoice
Identity typeUser-assigned managed identity (UAMI), not app registration
Identity granularityOne UAMI per environment (not per pipeline)
Trust scopePinned to the environment claim, not the branch
RBAC scopeResource group, not subscription
Remote stateOIDC + use_azuread_auth = true, shared key access disabled

Why UAMIs? They live in your subscription, don't need Application Administrator rights to manage, and follow the lifecycle of the resource group they belong to. Why one per environment? Pipeline-per-identity explodes into hundreds of identities. Environment-per-identity maps cleanly to deployment scopes.

Part 1 - GitHub Actions

Step 1: Create the identity and federate it

Two commands per environment. That's it.

az identity create -g rg-platform-identity -n id-tf-prod -l eastus

az identity federated-credential create \
  --name github-prod \
  --identity-name id-tf-prod \
  --resource-group rg-platform-identity \
  --issuer https://token.actions.githubusercontent.com \
  --subject repo:contoso/platform:environment:prod \
  --audiences api://AzureADTokenExchange

Repeat for nonprod. No secret is created anywhere.

Step 2: Wire it up in GitHub

In repo Settings → Environments, create nonprod and prod. On prod, add required reviewers and a branch rule restricting deployments to main. Then add three environment variables (not secrets - these aren't sensitive): AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID.

The workflow itself stays small:

permissions:
  id-token: write
  contents: read

jobs:
  apply:
    runs-on: ubuntu-latest
    environment: prod
    env:
      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
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init && terraform apply -auto-approve

Three things make this secure:

  • id-token: write is the only elevated permission, and it doesn't grant write access to anything in GitHub, it just lets the runner mint a JWT.
  • The environment: line picks the right AZURE_CLIENT_ID and drives the sub claim. The federation refuses anything else.
  • No azure/login step is needed for Terraform. The azurerm provider reads GitHub's OIDC environment variables automatically.

Part 2 - Azure DevOps

The model is identical. The mechanics are different.

ADO offers two creation paths for a WIF service connection: automatic (it creates an app registration for you) and manual (you bring your own UAMI). For platform teams, manual + UAMI is almost always the better choice to ensure identity lives where governance lives.

The flow is a small dance between the two portals:

  1. In Azure DevOps, create a new ARM service connection → choose Workload Identity Federation (manual) → fill in your UAMI's client ID, tenant ID, and subscription. Save as draft. ADO shows you an issuer URL and a subject identifier.
  2. In Azure, on the UAMI, add a federated credential using the values ADO showed you. The subject looks like sc://contoso/platform/azure-prod.
  3. Back in ADO, click Verify and save.

In the pipeline, the service connection only "activates" if a task in the job loads it. The simplest way is the AzureCLI@2 task:

- task: AzureCLI@2
  inputs:
    azureSubscription: azure-prod   # the WIF service connection
    scriptType: bash
    scriptLocation: inlineScript
    inlineScript: |
      terraform init && terraform apply -auto-approve
  env:
    ARM_USE_OIDC: "true"
    ARM_CLIENT_ID: $(AZURE_CLIENT_ID)
    ARM_TENANT_ID: $(AZURE_TENANT_ID)
    ARM_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID)
    ARM_ADO_PIPELINE_SERVICE_CONNECTION_ID: $(SERVICE_CONNECTION_ID)
    SYSTEM_ACCESSTOKEN: $(System.AccessToken)
    SYSTEM_OIDCREQUESTURI: $(System.OidcRequestUri)

For teams converting dozens of legacy connections, the Azure DevOps team published a PowerShell helper that walks every ARM service connection in a project and converts them in place. There's a 7-day rollback window on each connection, which makes the migration genuinely low-risk.

Don't forget the state file

The Terraform state is your real blast radius. With OIDC, it's almost free to lock it down too. The same UAMI can read and write blob data without the storage account key:

backend "azurerm" {
  resource_group_name  = "rg-tfstate"
  storage_account_name = "sttfstateprodeastus"
  container_name       = "platform-prod"
  key                  = "platform.tfstate"
  use_oidc             = true
  use_azuread_auth     = true
}

Grant the UAMI Storage Blob Data Contributor on the container (not the account), disable shared key access on the storage account, and you've removed the last secret in the pipeline.

RBAC and break-glass

Federation removes a credential, not a privilege. A few habits worth keeping:

  • Scope role assignments to resource groups, not subscriptions. The whole point of federation is that scoping is now trivially easy.
  • Use Role Based Access Control Administrator instead of User Access Administrator if your Terraform creates role assignments. It's a more recent, narrower role.
  • Have a documented break-glass. If GitHub or ADO has a token-service incident, you still need a path to ship a hotfix. A single hardware-key-protected emergency app registration in a separate identity boundary works well, audited monthly.
  • Monitor sign-ins. Every federated exchange shows up in Entra sign-in logs as a service principal sign-in. Pipe these to Sentinel and alert on anomalies like sign-ins outside expected hours, or from IPs outside GitHub's published ranges.

The errors you will hit (and what they really mean)

SymptomWhat it actually is
AADSTS70021: No matching federated identity record foundCase-sensitive mismatch in iss, sub, or aud. Almost always a trailing slash or a capitalised character
AADSTS700016: Application not found in directoryWrong client ID or tenant. Not a federation problem
403 on a resource even though token exchange workedFederation is fine. Your RBAC isn't. Check the exact scope
Unable to determine OIDC token (ADO)No task in the job loaded the service connection. Add an AzureCLI@2 step
Works on main, fails on tagsYou pinned sub to a branch ref. Add a second federated credential for tags, or move to environment-based scoping

Migrating without a maintenance window

You almost never get to do this on a greenfield repo. The order that has worked for me on legacy estates:

  1. Create the new UAMI alongside the old service principal, with the same role assignments.
  2. Federate one canary pipeline. Verify it deploys equivalently.
  3. Cut over pipelines in waves, lowest-risk environment first.
  4. Once a full release cycle passes cleanly, disable the old SP's secret.
  5. Wait another cycle. Then delete the SP entirely.
  6. Add a CI gate that fails any new pipeline introducing ARM_CLIENT_SECRET.

The old and new auth methods coexist on the same subscription throughout. There's no hard cutover and no maintenance window, just a steady drift toward zero secrets.

Wrapping up

If you do nothing else after reading this, do one thing: search your CI variable groups for ARM_CLIENT_SECRET. Every result is an outage or a breach waiting to happen.

Federation is one of those rare changes that's both more secure and less work to operate. Once you've set it up, you stop thinking about credential rotation, secret expiry, and quarterly access reviews for service principals. The pipeline simply runs, and the audit trail is in Entra where it belongs.

That's a good trade.

Updated May 02, 2026
Version 2.0
No CommentsBe the first to comment