Storing an Azure service principal secret in GitHub repository secrets was the standard pattern for years, and it was always a liability: long lived credentials, manual rotation, and full subscription access sitting one repo compromise away. OIDC federation removes the credential entirely. The workflow requests a short lived token from GitHub, Azure validates it against a trust you configured once, and issues an access token scoped to a specific identity. Nothing stored, nothing to rotate, nothing to leak.
How the Trust Works
GitHub runs an OIDC provider at token.actions.githubusercontent.com. Each workflow run can mint a JWT whose subject claim encodes exactly where it came from: repository, branch, environment, or tag. On the Azure side you create a federated credential on an Entra application or a user assigned managed identity that says: tokens from this issuer with this exact subject may authenticate as this identity. The subject matching is the security boundary, so precision matters.
resource "azuread_application" "gha_prod" {
display_name = "gha-orders-prod"
}
resource "azuread_service_principal" "gha_prod" {
client_id = azuread_application.gha_prod.client_id
}
resource "azuread_application_federated_identity_credential" "prod_env" {
application_id = azuread_application.gha_prod.id
display_name = "github-prod-environment"
issuer = "https://token.actions.githubusercontent.com"
audiences = ["api://AzureADTokenExchange"]
subject = "repo:contoso/orders:environment:production"
}
resource "azurerm_role_assignment" "gha_prod" {
scope = azurerm_resource_group.apps_prod.id
role_definition_name = "Contributor"
principal_id = azuread_service_principal.gha_prod.object_id
}
Note the subject: it requires the workflow to run in the GitHub environment named production, not merely in the repository. That composes beautifully with GitHub environment protection rules, so the token that can touch production only exists after required reviewers approve the deployment. Scope subjects to environments for deploys and to specific branches for anything else; a bare repo wildcard subject means any workflow in any branch, including one from a malicious pull request in a poorly configured repo, can assume the identity.
The Workflow Side
name: deploy-prod
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Deploy
run: |
az containerapp update \
--name orders-api \
--resource-group rg-apps-prod \
--image ${{ vars.ACR_SERVER }}/orders-api:${{ github.sha }}
Two things people miss. The permissions block granting id-token write is mandatory, without it the OIDC token request fails with a confusing error. And the client ID, tenant ID, and subscription ID are configuration, not secrets, so store them as repository variables where they are visible and auditable rather than pretending identifiers are sensitive.
Terraform in CI with OIDC
The azurerm provider and backend both speak OIDC natively: set use_oidc to true (or ARM_USE_OIDC in the environment) along with the client and tenant IDs, and both state access and resource management authenticate through the federated identity. Give the plan job a read only identity scoped for plan, and the apply job a separate writer identity behind the production environment gate. That split means a compromised pull request can at worst read a plan, never mutate infrastructure.
One identity per repository per environment, subjects pinned to environments, roles scoped to resource groups. Set it up once with the Terraform above and secret rotation simply exits your life.
Cheers
Osama
Leave a comment