Azure Federated Identity Credentials Instead of Client Secrets
Replace app-registration client secrets in GitHub Actions with Azure federated identity credentials: OIDC issuer, subject, audience, leftover secrets, and az commands.
A GitHub secret named AZURE_CLIENT_SECRET is a password for an Entra app that often has Contributor on a subscription. Anyone who can read Actions secrets, a forked workflow that exfiltrates secrets.*, or a leaked .env from a self-hosted runner owns that app until you rotate. Azure federated identity credentials replace that password with a short-lived GitHub OIDC token Azure will exchange only if issuer, subject, and audience match.
This page is the CI cutover: app (or user-assigned identity) + federated credential + azure/login + delete the secret. It is not a CIEM explainer and not a landing-zone RBAC design. Product docs: federated identity credentials.
Client secrets in CI
Inventory before you invent a new app:
# Apps with password credentials still present
az ad app list --all --query "[?passwordCredentials[0]!=null].{app:displayName,id:appId,secrets:passwordCredentials[].endDateTime}" -o json
# Who can use this app in Azure (object id, not app id)
az role assignment list --assignee "${APP_OBJECT_ID}" --all -o table
Failure mode: you rotate AZURE_CLIENT_SECRET in GitHub and leave the old password on the app. Entra keeps both until expiry. az ad app credential list --id "${APP_ID}" is the source of truth, not the GitHub UI.
Typical blast radius of a stolen CI secret:
| Binding on the app | What a leaked secret can do |
|---|---|
Contributor on the subscription |
Deploy, delete, read most data-plane APIs the RG allows |
User Access Administrator |
Grant itself Owner; standing admin |
Key Vault Secrets Officer on prod vault |
Dump secrets the pipeline was meant to inject one at a time |
Pipelines that print az account get-access-token into logs are the same class of bug as committing the secret. Federation does not help if the job then writes a refresh token to an artifact.
Federated credentials on app registrations
Create (or reuse) an app registration used only for this repo’s production environment. Assign RBAC to the service principal, then add the federated credential.
APP_ID="$(az ad app create --display-name "gha-payments-prod" --query appId -o tsv)"
az ad sp create --id "${APP_ID}"
az ad app federated-credential create --id "${APP_ID}" --parameters '{
"name": "github-prod-env",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:myorg/payments:environment:prod",
"description": "GitHub Actions environment prod",
"audiences": ["api://AzureADTokenExchange"]
}'
User-assigned managed identity variant (no app registration):
az identity federated-credential create \
--name github-prod-env \
--identity-name mi-gha-payments \
--resource-group rg-identity \
--issuer "https://token.actions.githubusercontent.com" \
--subject "repo:myorg/payments:environment:prod" \
--audience "api://AzureADTokenExchange"
Failure mode: federated credential on app A, RBAC on app B (or on a leftover SP from a tutorial). azure/login succeeds (token exchange) then every ARM call returns 403. Match client ID in the workflow to the principal that has the role assignment.
GitHub job:
permissions:
id-token: write
contents: read
jobs:
deploy:
environment: prod
steps:
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
After three green deploys, delete passwords:
az ad app credential reset --id "${APP_ID}" --append false --years 1 # do not; this creates a new secret
# Instead delete each secret by key id:
az ad app credential delete --id "${APP_ID}" --key-id "${KEY_ID}"
credential reset is a trap: it mints a new client secret. You are trying to have zero secrets.
GitHub OIDC issuer settings
Issuer for GitHub-hosted and most GitHub Enterprise Cloud jobs is https://token.actions.githubusercontent.com. GitHub Enterprise Server uses your GHE hostname; a cloud issuer string will never match.
Subject must be exact. Common shapes:
| Intent | subject |
|---|---|
| One environment with required reviewers | repo:ORG/REPO:environment:prod |
| Only the default branch | repo:ORG/REPO:ref:refs/heads/main |
| Pull requests (usually nonprod only) | repo:ORG/REPO:pull_request |
Audience for Azure is api://AzureADTokenExchange unless you set a custom audience on both the credential and the login action. A mismatch is a silent AADSTS error, not a GitHub “secret missing” error.
Reusable workflows: the sub claim is the caller repo, not the reusable-workflow repo, unless you opted into the reusable-workflow subject. Copy-pasting a credential from the template repo into production is the usual Monday outage.
Failure mode: environment:Production in GitHub and environment:prod in Entra. Names are case-sensitive. Another: branch protection off, subject pinned to main, and developers merging unsigned commits—federation authenticates GitHub, not your code review policy.
Decode the token GitHub minted (ACTIONS_ID_TOKEN_REQUEST_URL) in a throwaway job and compare claims to:
az ad app federated-credential list --id "${APP_ID}" -o json
What still uses secrets
Federation does not empty Key Vault. These still need a secret or a different trust:
- Confidential OAuth clients (web apps exchanging a client secret for user tokens)—that is not GitHub OIDC.
- SaaS that only stores
tenant_id / client_id / client_secretand cannot send an OIDC assertion. - Azure DevOps service connections not converted to workload identity federation (different issuer:
https://vstoken.dev.azure.com/{org}). - Jenkins / old self-hosted agents with no OIDC issuer you can list on the app.
- Storage account keys, SQL admin passwords, ACR admin user—those are resource keys, not Entra client secrets. Kill admin users; use RBAC and federation for the pipeline identity.
If a vendor cannot federate, mint a secret with a 90-day expiry, store it in GitHub environment secrets (not repo secrets), alert on Add service principal credentials in Entra audit logs, and track it as a CIEM exception with an owner. Standing Contributor + never-expiring password is the toxic combination pattern; federation removes one half.
Graph the leftover: every app with passwordCredentials and a role assignment on a production subscription. That query belongs in attack path analysis once the identity is a node; this page is the cutover so those nodes stop being long-lived secrets.
Checklist
- One app or user-assigned identity per production GitHub environment; RBAC on that object id
- Federated credential issuer/subject/audience match a decoded GitHub id-token
- Subject is environment- or ref-scoped, not
repo:ORG/REPO:* -
azure/loginuses client id + tenant + subscription; nocredsJSON -
az ad app credential listempty (or only documented vendor exceptions with expiry) - Entra audit alert on new password credentials for pipeline apps
- Azure DevOps / Jenkins called out separately; do not assume GitHub settings apply
Related: CIEM explained · Zero trust cloud architecture