Skip to content
damionas
No. 24DevOpsNov 26, 20259 min read

GitHub Actions → Azure With OIDC Federated Identity: The Setup That Survived Our SOC 2 Audit

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.

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/login between 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:

  1. "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 vars block in the GitHub repo. They moved on faster than any audit conversation I've ever had.

  2. "What prevents a PR from a fork from deploying?" Two layers: the federated credential is pull_request-only with a non-deploying RBAC, and id-token: write doesn't fire on pull_request from 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_request subject 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.

GitHub ActionsOIDCFederated IdentityEntra ID

Conversation

Reactions & comments

Liked 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.

Loading conversation…

More from DevOps

See all →