Client secrets in GitHub Actions workflows have a habit of becoming production incidents. They leak into logs, they expire on a Friday afternoon, and they sit in repository secret stores long after a contractor has left. Workload Identity Federation removes the secret entirely: GitHub mints a short lived OIDC token on every run, Entra ID validates it against a federated credential on your App Registration, and your pipeline gets an Azure access token without anyone storing a password.

This post walks through the full setup, from creating the App Registration to running a passwordless deployment, using a single shell session and one workflow file.

Prerequisites

  • An Azure subscription where you have Owner or User Access Administrator on at least the target resource group
  • Permission in Entra ID to create App Registrations (Application Administrator or Cloud Application Administrator)
  • Azure CLI 2.60 or later, signed in with az login
  • A GitHub repository you can edit, with permission to add Actions secrets and variables

You do not need to install any GitHub apps, and you do not need a self hosted runner. Federation works on standard GitHub hosted runners.

Step 1: Create the App Registration and Service Principal

The App Registration represents your pipeline as a security principal inside Entra ID. The accompanying Service Principal is what receives Azure RBAC role assignments.

# Choose a name that matches the repo. This makes audit logs readable.
APP_NAME="gh-oidc-contoso-infra"
SUBSCRIPTION_ID=$(az account show --query id -o tsv)
TENANT_ID=$(az account show --query tenantId -o tsv)

# Create the App Registration
APP_ID=$(az ad app create --display-name "$APP_NAME" --query appId -o tsv)

# Create the matching Service Principal
az ad sp create --id "$APP_ID"

echo "App (client) ID:    $APP_ID"
echo "Tenant ID:          $TENANT_ID"
echo "Subscription ID:    $SUBSCRIPTION_ID"

Keep these three values handy. They go into the GitHub workflow later, and none of them are secret: they identify the principal but cannot be used to sign in on their own.

Step 2: Add a Federated Credential

A federated credential tells Entra ID which GitHub OIDC tokens to trust. The subject field is the critical part: it pins the trust to a specific repository, branch, environment, or pull request context. A loose subject is a security hole, so be deliberate.

The example below scopes the trust to the main branch of a single repository. Adjust the GH_OWNER and GH_REPO variables to match your setup.

GH_OWNER="contoso"
GH_REPO="infra"

cat > federated-credential.json <<EOF
{
  "name": "github-main",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:${GH_OWNER}/${GH_REPO}:ref:refs/heads/main",
  "description": "GitHub Actions, main branch",
  "audiences": ["api://AzureADTokenExchange"]
}
EOF

az ad app federated-credential create \
  --id "$APP_ID" \
  --parameters federated-credential.json

For workflows that run from pull requests or environments, the subject takes one of these forms:

  • repo:OWNER/REPO:pull_request for any pull request
  • repo:OWNER/REPO:environment:prod for runs targeting a named environment
  • repo:OWNER/REPO:ref:refs/tags/v* for tag based releases (with a wildcard)

Add one federated credential per subject you want to allow. A single App Registration can hold up to 20 federated credentials, which is plenty for most pipelines.

Step 3: Assign Azure RBAC

The Service Principal still needs Azure permissions. Grant only what the pipeline actually needs, and grant it at the smallest scope that works. A pipeline that deploys to one resource group should not be Contributor at subscription scope.

RG_NAME="rg-contoso-prod"

# Assign Contributor on a single resource group
az role assignment create \
  --assignee "$APP_ID" \
  --role "Contributor" \
  --scope "/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RG_NAME}"

If your workflow needs to assign roles itself (for example, when deploying a landing zone), use User Access Administrator instead, and pin the federated credential to a release tag or protected environment so the elevated rights cannot be used from a feature branch.

Step 4: Configure GitHub Actions

In GitHub, open the repository, then Settings, Secrets and variables, Actions, and add three repository variables (not secrets, since none of these values are sensitive):

  • AZURE_CLIENT_ID: the App (client) ID from Step 1
  • AZURE_TENANT_ID: the tenant ID from Step 1
  • AZURE_SUBSCRIPTION_ID: the subscription ID from Step 1

Then create or update .github/workflows/deploy.yml. The two lines that make federation work are permissions: id-token: write and the absence of a client-secret input on the azure/login action.

name: Deploy infrastructure

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  id-token: write   # required for OIDC
  contents: read    # required to checkout the repo

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

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

      - name: Verify identity
        run: az account show --output table

      - name: Deploy Bicep
        run: |
          az deployment group create \
            --resource-group rg-contoso-prod \
            --template-file main.bicep \
            --parameters env=prod

Commit and push. The first run should show a successful sign in step, followed by an az account show output that lists the Service Principal you created. No secret ever leaves Azure.

Step 5: Use the Same Principal from PowerShell

If a step needs Microsoft Graph or Azure PowerShell, you can hand off the OIDC token without falling back to a secret. The azure/login action exports an idToken that Connect-AzAccount and Connect-MgGraph both accept.

      - name: Sign in to Azure
        id: azlogin
        uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
          enable-AzPSSession: true

      - name: Run PowerShell
        uses: azure/powershell@v2
        with:
          azPSVersion: latest
          inlineScript: |
            Get-AzResourceGroup | Select-Object ResourceGroupName, Location

The enable-AzPSSession: true flag wires up the Azure PowerShell context using the same federated token, so your scripts run as the federated identity with no extra wiring.

Putting It All Together

From a fresh shell, the entire setup is a handful of commands and one workflow file:

  1. Create the App Registration and Service Principal
  2. Add a federated credential pinned to your repository and branch
  3. Grant Azure RBAC at the smallest workable scope
  4. Add three non secret repository variables
  5. Add permissions: id-token: write and an azure/login step to your workflow

After this is in place, audit logs become clean (every action is attributable to a specific repo and branch), expiry tickets stop appearing, and there is no secret to leak if a runner is compromised. If a developer leaves, you do not rotate anything: the principal is bound to the repository, not to a person.

Tightening the Trust

A few small habits keep federated credentials safe:

  • Prefer environment based subjects (environment:prod) for production, and gate those environments with required reviewers
  • Use a separate App Registration per repository, so a single compromised repo cannot move laterally
  • Avoid wildcard subjects unless absolutely needed, and never accept a federated credential whose subject is just repo:OWNER/REPO:*
  • Review federated credentials quarterly the same way you would review service accounts

Workload Identity Federation is the rare modernisation that is simpler than what it replaces and more secure at the same time. Once a pipeline runs without secrets, it is hard to go back.