Azure calls its version of this a federated identity credential: a record attached to an app registration saying “a token from this issuer, with this subject, may sign in as this application”. No client secret exists.
Create the app registration
Section titled “Create the app registration”az ad app create --display-name github-deploy-productionaz ad sp create --id APP_IDThe app registration is the identity; the service principal is its instance in your tenant. Both are needed — role assignments attach to the service principal.
Note what you are not doing: no --sdk-auth, no create-for-rbac, no client secret. If you find
yourself copying a JSON blob into a repository secret, you are on the old path.
Add the federated credential
Section titled “Add the federated credential”az ad app federated-credential create --id APP_ID --parameters '{ "name": "github-main", "issuer": "https://token.actions.githubusercontent.com", "subject": "repo:OWNER/REPO:ref:refs/heads/main", "audiences": ["api://AzureADTokenExchange"], "description": "Deployments from the main branch"}'Four fields carry the meaning:
issuer — GitHub’s OIDC issuer, identical for every GitHub.com repository.
subject — the sub claim this credential accepts. This is the security boundary, and it must
match exactly: Azure does not support wildcards here, so each ref or environment needs its own
federated credential record.
audiences — api://AzureADTokenExchange is what azure/login requests.
name — an identifier for this credential, useful when an app has several.
Subjects for each trigger
Section titled “Subjects for each trigger”| Job | subject |
|---|---|
Push to main | repo:OWNER/REPO:ref:refs/heads/main |
| Push of a tag | repo:OWNER/REPO:ref:refs/tags/v1.0.0 |
| Pull request | repo:OWNER/REPO:pull_request |
Job with environment: production | repo:OWNER/REPO:environment:production |
Prefer the environment form. It composes with environment protection rules, so the credential is only usable by a job that has passed required reviewers.
For organisations needing patterns, Azure supports a flexible federated identity credential using a claims-matching expression rather than a literal subject. It is more capable and correspondingly easier to write too broadly; if you use it, restrict it to a specific repository rather than a repository-owner pattern.
Logging in
Section titled “Logging in”permissions: contents: read id-token: write
jobs: deploy: runs-on: ubuntu-latest environment: production steps: - uses: azure/login@v3 with: client-id: ${{ vars.AZURE_CLIENT_ID }} tenant-id: ${{ vars.AZURE_TENANT_ID }} subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- run: az account showvars, not secrets. These three are identifiers, not credentials — useless without a federated
credential trusting your repository. Storing them as secrets masks them in logs, which makes
debugging a federation failure noticeably harder for no security benefit.
RBAC scoping
Section titled “RBAC scoping”Federation controls who signs in. Azure RBAC controls what they can do, and it is assigned separately:
az role assignment create \ --assignee APP_ID \ --role "Website Contributor" \ --scope /subscriptions/SUB_ID/resourceGroups/example-rg/providers/Microsoft.Web/sites/example-appTwo rules:
Assign at the narrowest scope that works — a specific resource, or a resource group. Contributor at subscription scope means the deploy identity can delete anything in the subscription, including things it has no business touching.
Prefer a specific built-in role over Contributor. “Website Contributor”, “AcrPush” and “Azure Kubernetes Service Cluster User” are far smaller grants than the general-purpose ones.
Avoid granting User Access Administrator or Owner. An identity that can assign roles can grant itself anything, which makes the rest of the scoping decorative.
Diagnosing failures
Section titled “Diagnosing failures”| Symptom | Usual cause |
|---|---|
AADSTS70021: No matching federated identity record found | The subject does not match the actual sub claim |
Same, after adding environment: | The subject changed to the environment: form; add a credential for it |
AADSTS700016: Application not found in the directory | Wrong client ID, or the service principal was never created |
Unable to get ACTIONS_ID_TOKEN_REQUEST_URL | id-token: write missing from permissions |
| Login succeeds, the deployment fails | Federation is fine; the role assignment is missing or too narrow |
That last row is worth internalising. azure/login succeeding proves the federation works. Any
failure after it is an RBAC problem, and looking for it in the federated credential wastes time.
AADSTS70021 names no expected value, so compare against reality: print the actual claims with the
inspection step in OIDC, then compare with:
az ad app federated-credential list --id APP_ID --query "[].{name:name, subject:subject}" -o tableMultiple environments
Section titled “Multiple environments”The pattern that scales:
-
One app registration per environment —
github-deploy-staging,github-deploy-production. -
One federated credential on each, with the subject naming the matching GitHub environment.
-
Role assignments per app, scoped to that environment’s resource group.
-
Environment variables in GitHub holding each app’s client ID, so the workflow needs no conditionals.
Separate app registrations rather than one with several credentials, because the role assignments differ. One identity that can deploy to both environments is one compromise away from production.
Exercise
Section titled “Exercise”-
Create an app registration and service principal in a test tenant.
-
Add a federated credential with the subject
repo:OWNER/REPO:ref:refs/heads/main. -
Assign Reader at one resource group’s scope.
-
Run a workflow on
mainwithid-token: writethat logs in and runsaz account show. Confirm success with no secret stored. -
Add
environment: productionto the job. Confirm it now fails withAADSTS70021— the subject changed. -
Add a second federated credential for the environment subject and confirm it works again. You now know what that error means for life.