Setup:
1. **App registration.** Create one (or reuse). Note the application (client) ID, directory (tenant) ID, subscription ID.
2. **Federated credential.** Entra > App registrations > your app > Certificates & secrets > Federated credentials > Add:
- Issuer: `https://token.actions.githubusercontent.com`
- Subject identifier: `repo:[org]/[repo]:environment:[env]` (or `:ref:refs/heads/main` for branch-scoped)
- Audience: `api://AzureADTokenExchange`
Use environment-scoped subjects so only the protected environment can deploy.
3. **Role assignment.** Give the app registration Contributor (or narrower) on the target resource group/subscription. The identity is the app registration's service principal.
4. **Workflow.**
```yaml
permissions:
id-token - write
contents: read
steps:
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
```
No client secret anywhere. The client/tenant/subscription IDs are not secrets.
Traps:
- **Missing `id-token - write`.** Without it, GitHub will not mint the OIDC token and azure/login fails with a permissions error. This is the number one setup failure.
- **Subject mismatch.** `repo:org/repo:environment:prod` vs the workflow running in environment `production` = token rejected. Copy the subject from the workflow, do not hand-type.
- **azure/login version.** v2 is current; v1 uses a different input shape.
- **Rotating away from secrets.** If the repo previously used a client secret, delete the secret from the app registration after OIDC works, so nobody can fall back to it.
Verify: run the workflow, confirm the azure/login step succeeds and a subsequent `az` step shows the service principal identity; then delete any old client secret and re-run.