bbabafemi
All posts
DevOps

Federating GitHub Actions to Azure with OIDC — no more client secrets

A walkthrough of how to deploy from GitHub Actions to Azure without storing a client secret anywhere. Faster, safer, easier to rotate.

September 23, 2025 3 min readby Babafemi Bulugbe

If you're still pasting an AZURE_CREDENTIALS JSON blob into your GitHub repo secrets, stop. There's a better pattern that's been generally available for years now: OIDC federated credentials.

The short version: GitHub Actions issues a short-lived JWT for each workflow run. Azure trusts that JWT, exchanges it for an access token, and your job runs with no long-lived secret stored anywhere.

Here's how to set it up end-to-end.

What you're building

GitHub Actions workflow
        │  (issues OIDC token claiming "I am repo X, branch main")
        ▼
Azure AD app registration
        │  (federated credential trusts that exact claim)
        ▼
Service principal with RBAC on your Azure resources

No client secret. No credential rotation. Permissions scoped to a specific repo and branch.

Step 1: Create the Azure AD app + service principal

APP_NAME="sp-github-myrepo-deploy"

# Create the app registration and grab its IDs
APP_ID=$(az ad app create --display-name "$APP_NAME" --query appId -o tsv)
az ad sp create --id "$APP_ID"

# Grant Contributor at the resource group scope (or scope tighter)
SUB_ID=$(az account show --query id -o tsv)
az role assignment create \
  --role contributor \
  --subscription "$SUB_ID" \
  --assignee "$APP_ID" \
  --scope "/subscriptions/$SUB_ID/resourceGroups/$RG_NAME"

Use the principle of least privilege — Contributor at the resource group level, not the subscription level.

Step 2: Create the federated credential

This is the magic step. You're telling Azure AD: "trust GitHub-issued tokens, but only when they claim to come from this exact repo and branch."

APP_OBJ_ID=$(az ad app show --id "$APP_ID" --query id -o tsv)

cat > /tmp/fic.json <<EOF
{
  "name": "github-myorg-myrepo-main",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:myorg/myrepo:ref:refs/heads/main",
  "audiences": ["api://AzureADTokenExchange"]
}
EOF

az ad app federated-credential create --id "$APP_OBJ_ID" --parameters /tmp/fic.json

The subject claim is the lock — only workflows running from myorg/myrepo on the main branch can use this. You can also scope to a specific environment, pull request, or tag.

Step 3: Add the IDs to your GitHub repo

Settings → Secrets and variables → Actions → New repository secret:

  • AZURE_CLIENT_ID — the appId from step 1
  • AZURE_TENANT_IDaz account show --query tenantId -o tsv
  • AZURE_SUBSCRIPTION_IDaz account show --query id -o tsv

These aren't really "secrets" — they're identifiers. But putting them in repo secrets is the cleanest pattern.

Step 4: Configure the workflow

permissions:
  id-token: write   # required for OIDC
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Login to Azure
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Run an Azure CLI command
        run: az account show

That's it. No creds: parameter, no JSON blob, no client secret.

A few things people get wrong

Forgetting id-token: write. Without that permission, the OIDC token isn't issued. The error is confusing — usually "AADSTS70021" or similar. If you see that, check your permissions block.

Subject claim mismatch. The federated credential's subject must exactly match what GitHub will claim. If you scope to ref:refs/heads/main and someone tries to deploy from a feature branch, it'll fail — which is exactly what you want, but easy to debug-fix the wrong way.

Scoping for environments. If you use GitHub Environments, the subject changes to repo:myorg/myrepo:environment:production. Add a separate federated credential per environment so prod and staging deploys can have different Azure permissions.

Why bother?

Three reasons, in order of importance:

  1. Nothing to leak. A GitHub repo compromise can't expose a long-lived Azure credential because there isn't one.
  2. No rotation. Federated credentials don't expire on a schedule.
  3. Fine-grained scoping. You can grant deploy access to "this repo on the main branch only" — much tighter than a service principal credential.

If you're still using AZURE_CREDENTIALS JSON in your workflows, this is a 30-minute migration that genuinely makes you safer. Worth your Friday afternoon.