I used to rotate Azure service principal secrets in fourteen GitHub repos every quarter. Manually. Because a teammate had been burned by an automated rotation that desynced halfway through and took production down at 3am.
That setup is gone. We replaced it with OIDC federated credentials, workflow-scoped, no shared secrets, no rotation calendar, audit-friendly. This is what the migration actually looked like, the Bicep that backs it, and the four traps I hit before it stuck.
What changes
Instead of a service principal with a client secret stored as AZURE_CREDENTIALS (or split across four GitHub secrets), GitHub Actions exchanges its workflow-issued OIDC token for an Azure access token at run time. No long-lived secret on either side.
The trust lives in a federated credential on the Entra ID app:
az ad app federated-credential create --id "$APP_ID" --parameters '{
"name": "github-mvp-blog-prod",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:dammyboss/mvp-blog:environment:production",
"audiences": ["api://AzureADTokenExchange"]
}'
The subject claim is the operative bit. It pins this credential to this repo, this environment. A token issued for any other repo, branch, or environment fails the exchange.
The Bicep I actually deploy
param appDisplayName string
param repoSubject string = 'repo:dammyboss/mvp-blog'
resource app 'Microsoft.Graph/applications@v1.0' = {
uniqueName: appDisplayName
displayName: appDisplayName
}
resource sp 'Microsoft.Graph/servicePrincipals@v1.0' = {
appId: app.appId
}
resource fedProd 'Microsoft.Graph/applications/federatedIdentityCredentials@v1.0' = {
parent: app
name: 'github-prod'
properties: {
issuer: 'https://token.actions.githubusercontent.com'
subject: '${repoSubject}:environment:production'
audiences: ['api://AzureADTokenExchange']
}
}
resource fedPr 'Microsoft.Graph/applications/federatedIdentityCredentials@v1.0' = {
parent: app
name: 'github-pr'
properties: {
issuer: 'https://token.actions.githubusercontent.com'
subject: '${repoSubject}:pull_request'
audiences: ['api://AzureADTokenExchange']
}
}
// Read-only at subscription scope for the PR identity (what-if + plan)
resource readerForPr 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(subscription().id, sp.id, 'reader')
scope: subscription()
properties: {
roleDefinitionId: subscriptionResourceId(
'Microsoft.Authorization/roleDefinitions',
'acdd72a7-3385-48ef-bd42-f606fba81ae7') // Reader
principalId: sp.id
principalType: 'ServicePrincipal'
}
}
Note: two federated credentials per app, one for production deploys (environment-scoped), one for PR plans (pull_request-scoped). The PR identity gets Reader; the prod one gets Contributor on the target resource group only. Same SP, different blast radius depending on which subject was matched.
The workflow
name: deploy
on:
push:
branches: [main]
permissions:
id-token: write # required to mint the OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # MUST match the federated credential subject
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 }}
- run: |
az deployment sub create \
--location eastus \
--template-file infra/main.bicep \
--parameters env=prod
vars not secrets. None of these IDs are confidential, the security comes from the federated trust + the workflow's environment scoping.
The four traps
1. The subject claim is unforgiving
The federated credential's subject must match the OIDC token's claim exactly. Off-by-one casing, missing environment segment, branch instead of environment, silent failure with a generic 401. Debug by dumping the token claims once:
- name: Show OIDC claims
uses: actions/github-script@v7
with:
script: |
const token = await core.getIDToken("api://AzureADTokenExchange");
const [, payload] = token.split(".");
console.log(JSON.parse(Buffer.from(payload, "base64").toString()));
Compare the sub field to your federated credential's subject. The fix is always tightening the credential to match, never loosening token claims.
2. PRs from forks don't get a token
id-token: write is gated to workflows running in the head repo. A pull_request event from a fork won't mint an OIDC token, by design. If your CI needs to validate fork PRs against Azure, either route them through a maintainer-triggered workflow_dispatch, or use a separate read-everywhere identity scoped to a sandbox subscription.
3. RBAC is still your job
Federation handles authentication. Authorization is still RBAC, and the default role assignments after az ad sp create are nothing. I assign roles in Bicep at the narrowest scope that works:
resource contributorOnRg 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(rg.id, sp.id, 'contributor')
scope: rg
properties: {
roleDefinitionId: subscriptionResourceId(
'Microsoft.Authorization/roleDefinitions',
'b24988ac-6180-42a0-ab88-20f7382dd24c') // Contributor
principalId: sp.id
principalType: 'ServicePrincipal'
}
}
Resource-group scope, not subscription. Subscription-scoped Contributor is a finding waiting to happen.
4. The token has a one-hour life
The Azure access token returned by the exchange is good for sixty minutes. A long-running az deployment can outlive it and fail near the end with a fresh, confusing 401. Three options:
- Split the deploy into stages so each stays under an hour
- Re-run
azure/loginbetween stages (it's cheap, sub-second) - Hand off long jobs to an Azure Container App job triggered by the workflow, running under its own managed identity
I do the third for anything past 30 minutes.
What the auditor actually asked
Two questions, in this order:
"Show me where the production deploy credentials are stored." My answer: nowhere. Federated trust, no secret material on either side. I showed the federated credential entry in Entra ID and the
varsblock in the GitHub repo. They moved on faster than any audit conversation I've ever had."What prevents a PR from a fork from deploying?" Two layers: the federated credential is
pull_request-only with a non-deploying RBAC, andid-token: writedoesn't fire onpull_requestfrom a fork. I showed both in code.
That was the full Q&A on this control.
The numbers, after six months
- 0 secrets to rotate
- 3 minutes average time from a developer requesting a new pipeline to a working deploy (from ~2 days when SP secrets needed PR + secret-store provisioning)
- 1 federated credential modification incident (someone tried to add a second
repo:*:pull_requestsubject for a different repo on the same app, Bicep diff caught it)
What I'd do differently
If I were doing this from scratch I'd skip per-environment apps entirely. One app per repo, three federated credentials (pull_request, environment:staging, environment:production), three role assignments at three scopes. Less identity sprawl, the same blast-radius story, half the Bicep.
I would not touch azure/login@v1 for new projects. v2 has the OIDC code path baked in; v1 needed a brittle JWT exchange step and doesn't ship security fixes anymore.

Conversation
Reactions & commentsLiked this? Tap a reaction. Want to push back, share a war story, or ask a follow-up? Drop a comment below — replies are threaded and markdown works.