GitHub Actions OIDC to Azure: Passwordless Deployments Done Right


Executive Snapshot

GitHub ActionsWorkflow«AzureActiveDirectory»Microsoft Entra ID[Federated credential]Target Azure Resource(App Service / Storage)present OIDC JWTshort-lived access tokenvalidate subject claimdeploy with token (no secret)
GitHub Actions federates to Entra ID for a keyless Azure deployment token.
Category Recommendation
Auth Method Workload Identity Federation (OIDC) — no stored secrets
Azure Resource App Registration + Federated Credential
GitHub Side azure/login action with client-id, tenant-id, subscription-id
Secret Storage GitHub Repository Secrets or Environment Secrets (IDs only, not passwords)
Minimum RBAC Least-privilege role scoped to resource group, not subscription
Token Lifetime 10 minutes (non-renewable, no rotation required)
Production Readiness Use GitHub Environments + required reviewers for gated deployments

TL;DR

flowchart TD
    A[Workflow job starts] --> B[Request OIDC token from GitHub]
    B --> C[GitHub issues signed JWT]
    C --> D[azure/login presents token to Entra ID]
    D --> E{Federated credential subject matches?}
    E -->|No| F[Access denied]
    E -->|Yes| G[Entra ID issues short-lived access token]
    G --> H[Job calls Azure with token]
    H --> I[Action completes - no stored secret]
    classDef bad fill:#7a1f2b,stroke:#ff5a6e,color:#fff
    classDef ok fill:#1f6f43,stroke:#3ddc84,color:#fff
    class F bad
    class G,I ok
GitHub OIDC token exchanged with Entra ID for keyless Azure access.
  • Stop storing Azure service principal passwords in GitHub Secrets — OIDC Workload Identity Federation issues short-lived tokens automatically at runtime.
  • GitHub Actions OIDC with Azure requires three things: an App Registration, a Federated Credential that trusts your repo, and correct workflow permissions (id-token: write).
  • Tokens expire in 10 minutes and are scoped to a single job run — there is nothing to rotate, leak, or accidentally commit.
  • RBAC still matters — a passwordless identity with Owner on a subscription is just as dangerous as a long-lived secret with the same role; always scope to the minimum.
  • GitHub Environments add a critical safety layer — gate production deployments with required reviewers so a compromised workflow cannot self-approve a deploy.

Introduction

Every few months, a new headline emerges: credentials accidentally committed to a public repository, a CI/CD secret exposed in build logs, or a service principal password left unchanged for three years because rotating it would break something. These are not hypothetical risks — they are the inevitable outcome of treating long-lived secrets as a deployment mechanism.

Azure service principals with client secrets have been the default for GitHub Actions deployments since the platform launched. They work, but they carry a fundamental flaw: the secret must be stored somewhere, transmitted somewhere, and rotated on a schedule that teams routinely neglect.

OpenID Connect (OIDC) Workload Identity Federation changes the model entirely. Instead of exchanging a stored password for an access token, GitHub's OIDC provider issues a signed JSON Web Token that Azure's identity platform verifies cryptographically. No password is ever created, stored, or transmitted. The token lives for ten minutes and is valid only for the specific job that requested it.

This tutorial walks you through the complete setup — from creating the Azure App Registration to writing a production-grade deployment workflow — so you can eliminate credential sprawl from your CI/CD pipelines for good.


Prerequisites

  • An Azure subscription with permission to create App Registrations and assign RBAC roles (User Access Administrator or Owner at the target scope)
  • Azure CLI v2.50+ installed locally (az --version) or access to Azure Cloud Shell
  • A GitHub repository where you have Admin access (to configure secrets and environments)
  • Basic familiarity with GitHub Actions workflow syntax (you have written or edited a .github/workflows/*.yml file before)
  • Optional but recommended: GitHub CLI (gh) for scripting the secret configuration step

How OIDC Workload Identity Federation Works

Before touching a terminal, it is worth spending two minutes on the trust model. The diagram below shows the complete token exchange flow for a single GitHub Actions job.

sequenceDiagram
    participant GH as GitHub Actions Runner
    participant GHOIDC as GitHub OIDC Provider
(token.actions.githubusercontent.com) participant AAD as Microsoft Entra ID
(login.microsoftonline.com) participant AZ as Azure Resource Manager GH->>GHOIDC: Request OIDC token (JWT)
for current job context GHOIDC-->>GH: Signed JWT (exp: 10 min)
contains repo, ref, environment claims GH->>AAD: Exchange JWT for Azure access token
(client_id + federated_credential) AAD->>GHOIDC: Fetch JWKS to verify JWT signature GHOIDC-->>AAD: Public keys AAD-->>GH: Azure access token (Bearer) GH->>AZ: API call with Bearer token AZ-->>GH: Resource response

Key insight: Microsoft Entra ID reaches out to GitHub's OIDC discovery endpoint to verify the token's signature using GitHub's public keys. Your workflow never holds a password — it holds a short-lived, cryptographically verifiable assertion of identity.

The JWT contains claims that your Federated Credential uses as filters: repository, ref (branch or tag), environment, and job_workflow_ref. You can make the trust relationship as narrow or broad as you need.


Step 1 — Create the Azure App Registration and Service Principal

You can do this through the Azure Portal, but the CLI is reproducible and scriptable. Run the following commands in your terminal or Cloud Shell.

# Set variables — adjust to your environment
REPO="myorg/my-app"          # GitHub org/repo
APP_NAME="gh-actions-my-app" # Descriptive, unique name
RESOURCE_GROUP="rg-my-app-prod"
SUBSCRIPTION_ID=$(az account show --query id -o tsv)

# 1. Create the App Registration (no secret — we don't need one)
APP_ID=$(az ad app create \
  --display-name "$APP_NAME" \
  --query appId \
  --output tsv)

echo "App (Client) ID: $APP_ID"

# 2. Create the Service Principal for the App Registration
az ad sp create --id "$APP_ID"

# 3. Get the Object ID of the Service Principal (needed for role assignment)
SP_OBJECT_ID=$(az ad sp show \
  --id "$APP_ID" \
  --query id \
  --output tsv)

echo "Service Principal Object ID: $SP_OBJECT_ID"

# 4. Assign a role — scope to resource group, not subscription
az role assignment create \
  --assignee "$SP_OBJECT_ID" \
  --role "Contributor" \
  --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP"

echo "Setup complete. App ID: $APP_ID"

Principle of least privilege: Contributor on a resource group is the default in many guides, but you should prefer purpose-built roles where possible. Deploying only to Azure App Service? Use Website Contributor. Pushing images to ACR? Use AcrPush. Always start narrow and widen only when CI fails.


Step 2 — Add a Federated Credential to the App Registration

A Federated Credential is the configuration object in Entra ID that says: "Trust JWTs from GitHub's OIDC provider that match these specific claims." You can add up to 20 per App Registration.

The subject claim is the most important filter. It determines which combination of repo, branch/tag, and environment is allowed to assume this identity.

# Federated credential for the main branch
az ad app federated-credential create \
  --id "$APP_ID" \
  --parameters '{
    "name": "gh-actions-main-branch",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:myorg/my-app:ref:refs/heads/main",
    "description": "GitHub Actions deployments from main branch",
    "audiences": ["api://AzureADTokenExchange"]
  }'

# Federated credential scoped to a GitHub Environment (recommended for production)
az ad app federated-credential create \
  --id "$APP_ID" \
  --parameters '{
    "name": "gh-actions-production-env",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:myorg/my-app:environment:production",
    "description": "GitHub Actions deployments to production environment",
    "audiences": ["api://AzureADTokenExchange"]
  }'

Subject claim patterns you should know:

Scenario Subject Value
Specific branch repo:ORG/REPO:ref:refs/heads/main
Any branch repo:ORG/REPO:ref:refs/heads/* (wildcards require flexible federated credentials via claimsMatchingExpression; the standard subject field is exact-match only)
Tag pattern repo:ORG/REPO:ref:refs/tags/v*
Pull request repo:ORG/REPO:pull_request
GitHub Environment repo:ORG/REPO:environment:production

Security note: Avoid pull_request subjects for any credential with write access to Azure. A contributor with PR creation rights could trigger a deployment. Use pull_request_target events with extreme care, or restrict to read-only roles for PR workflows.


Step 3 — Configure GitHub Repository Secrets

You are not storing a password — you are storing three non-sensitive identifiers that tell the azure/login action which App Registration and tenant to target. Still, keeping them in Secrets (rather than hardcoded in the workflow YAML) is good hygiene and avoids leaking your tenant ID and subscription ID in public repositories.

# Using GitHub CLI — run from inside your cloned repository
TENANT_ID=$(az account show --query tenantId -o tsv)

gh secret set AZURE_CLIENT_ID --body "$APP_ID"
gh secret set AZURE_TENANT_ID --body "$TENANT_ID"
gh secret set AZURE_SUBSCRIPTION_ID --body "$SUBSCRIPTION_ID"

echo "Secrets configured:"
gh secret list

Alternatively, navigate to Repository → Settings → Secrets and variables → Actions → New repository secret and add the three values manually.

If you are using GitHub Environments (strongly recommended for production), scope the secrets to the environment instead:

gh secret set AZURE_CLIENT_ID \
  --body "$APP_ID" \
  --env production

Step 4 — Write the GitHub Actions Workflow

This is the complete, production-grade workflow. Read the inline comments — each decision has a reason.

# .github/workflows/deploy-azure.yml
name: Deploy to Azure (OIDC)

on:
  push:
    branches:
      - main
  workflow_dispatch: # Allow manual triggers

# CRITICAL: This permission block is required.
# Without id-token: write, the runner cannot request an OIDC token from GitHub.
# Without contents: read, actions/checkout cannot clone the repo.
permissions:
  id-token: write   # Required for OIDC token request
  contents: read    # Required for actions/checkout

jobs:
  deploy:
    name: Deploy Application
    runs-on: ubuntu-latest
    # Referencing a GitHub Environment unlocks:
    # - Environment-scoped secrets
    # - Required reviewers (approval gates)
    # - Deployment protection rules
    environment: production

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      # The azure/login action handles the full OIDC exchange:
      # 1. Requests JWT from GitHub OIDC provider
      # 2. Sends it to Entra ID for an Azure access token
      # 3. Makes the token available to subsequent az CLI calls
      - name: Authenticate to Azure (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      # Verify the login succeeded and the identity is what you expect
      - name: Verify Azure identity
        run: |
          az account show --query '{name:name, id:id, user:user.name}' -o table

      # Your actual deployment steps go here
      - name: Deploy to Azure App Service
        run: |
          az webapp deploy \
            --resource-group rg-my-app-prod \
            --name my-app-service \
            --src-path ./dist \
            --type zip

      # Always log out — belt-and-suspenders even though the token expires
      - name: Logout from Azure
        if: always()
        run: az logout

Workflow for Terraform deployments

If you are using Terraform rather than az CLI commands, the OIDC authentication method feeds into the AzureRM provider through environment variables:

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

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3

      - name: Terraform Init & Apply
        env:
          # AzureRM provider reads these environment variables automatically
          # when use_oidc = true is set in the provider block
          ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
          ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
          ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
          ARM_USE_OIDC: "true"
        run: |
          terraform init
          terraform apply -auto-approve

Your provider.tf should include:

# provider.tf
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.100"
    }
  }
}

provider "azurerm" {
  features {}

  # These are read from ARM_* environment variables set in the workflow.
  # No client_secret is required when use_oidc = true.
  use_oidc = true
}

Step 5 — Gate Production with GitHub Environments

A passwordless identity is not an audit trail or an approval gate — it just removes the secret. For production deployments, you also want human oversight. GitHub Environments provide this.

  1. Navigate to Repository → Settings → Environments → New environment
  2. Name it production (must match exactly what you used in the Federated Credential subject and the workflow environment: key)
  3. Enable Required reviewers and add your team leads or a dedicated ops team
  4. Optionally set a deployment branch policy to restrict deployments to main only
flowchart LR
    A([Push to main]) --> B[Build Job\nno Azure access]
    B --> C{Environment:\nproduction}
    C --> D([Reviewer Approval\nRequired])
    D --> E[Deploy Job\nOIDC token issued]
    E --> F([Azure Resource\nManager])

    style D fill:#f0a500,color:#000
    style E fill:#238636,color:#fff
    style F fill:#0078d4,color:#fff

The OIDC token is only issued at the point the deploy job starts — after the reviewer approves. If the deployment is rejected, no token is ever created and no Azure API is ever contacted. This is the model you want for production.


Pitfalls to Avoid

1. Missing id-token: write permission
This is the single most common error. If your workflow inherits permissions: {} (which GitHub now defaults to for security) or has an explicit block without id-token: write, the azure/login step will fail with a cryptic JWT error. Always declare permissions explicitly at the workflow or job level.

2. Subject claim mismatch
The Federated Credential subject must match the JWT claim exactly. refs/heads/main is not the same as refs/heads/master. Running a workflow from a branch other than the one in the subject will result in an AADSTS70021 error from Entra ID. Use the Azure Portal to inspect the credential and compare it against the ACTIONS_ID_TOKEN_REQUEST_URL JWT decoded at runtime.

3. Overly broad subject claims
Some quickstart guides suggest repo:ORG/REPO:* to avoid subject matching errors. This is convenient but dangerous — it allows any workflow event in your repository, including PRs from forks, to request a token. Prefer environment-scoped subjects for production credentials.

4. Granting Subscription-level Contributor
It is tempting to grant Contributor at subscription scope because it eliminates all permission errors immediately. Resist this. A misconfigured workflow or a supply-chain attack against an Action you use could delete or modify every resource in the subscription. Scope roles to the specific resource group or resource.

5. Not logging out in the always() block
The token expires in 10 minutes regardless, but az logout is a defense-in-depth measure and also suppresses warnings in the runner logs. Use if: always() to ensure it runs even after a failed step.

6. Mixing old AZURE_CREDENTIALS JSON secret with OIDC
The azure/login action supports both methods. If you have an old AZURE_CREDENTIALS JSON secret in your repository and you add the OIDC parameters, ensure you remove the creds: input. Having both present creates ambiguity and the action may silently prefer the wrong method.


Troubleshooting

Error Likely Cause Fix
Error: AADSTS70021: No matching federated identity record found Subject claim mismatch Compare Federated Credential subject to the actual workflow context (branch, environment name exact match)
Error: Failed to get ID token / 403 on OIDC endpoint Missing id-token: write permission Add permissions: id-token: write to workflow or job block
AuthorizationFailed on Azure API call RBAC role not propagated or wrong scope Wait 2-5 min for role propagation; verify with az role assignment list --assignee $APP_ID
AADSTS700016: Application not found Wrong client-id secret value Re-check AZURE_CLIENT_ID — it must be the App (client) ID, not the Object ID
Error: Login failed with Error: az cli is not installed Missing Azure CLI on custom runner Add an az install step or switch to ubuntu-latest hosted runner
Terraform: no subscription available Missing ARM_SUBSCRIPTION_ID env var Ensure all three ARM_* variables are exported in the Terraform step's env: block

Decoding the OIDC JWT for debugging: If you need to inspect exactly what claims GitHub is issuing, add a temporary debug step before your azure/login step:

# Temporary debug step — remove before merging to main
- name: Debug OIDC token claims
  run: |
    OIDC_TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
      "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" \
      | jq -r '.value')
    echo "$OIDC_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .

This prints the decoded JWT payload — look at the sub claim and compare it character-for-character with your Federated Credential subject value.


Key Takeaways

  • GitHub Actions OIDC with Azure (Workload Identity Federation) eliminates the need for stored client secrets entirely — the authentication relies on cryptographic token exchange, not shared passwords.
  • Three Azure-side steps are required: create an App Registration, create a Federated Credential with the correct subject claim, and assign a least-privilege RBAC role at the appropriate scope.
  • One GitHub-side permission is non-negotiable: id-token: write must be present in your workflow permissions block or the runner cannot request an OIDC token.
  • Subject claims are exact-match filters — scope them to a GitHub Environment for production, branch references for development, and never use wildcards for write-access credentials.
  • GitHub Environments with required reviewers complete the security model — OIDC removes the credential risk, Environments add the human approval gate, together they create a defensible production deployment pipeline.
  • OIDC tokens are time-bounded by design (10 minutes) — there is no rotation schedule to miss, no expiry notification to ignore, and no leaked credential to revoke.
  • The same pattern works for Terraform, Bicep, Helm, and any tool that calls Azure APIs — the azure/login action sets the auth context for all subsequent steps in the job.

Next Steps

  1. Audit your existing workflows for AZURE_CREDENTIALS JSON secrets or clientSecret patterns — run gh secret list across your repositories and create a migration backlog.
  2. Implement GitHub Environment protection rules for every environment that has an OIDC credential with write access to Azure production resources.
  3. Enable Microsoft Entra ID sign-in logs and create a Log Analytics alert for unexpected service principal authentications from the token.actions.githubusercontent.com issuer — this is your detection layer if a workflow is ever abused.
  4. Review the Azure built-in roles reference and replace Contributor assignments with purpose-built roles where your deployment scope allows.
  5. Consider centralising Federated Credentials into a dedicated Entra ID App Registration per deployment environment (not per repository) to simplify RBAC management at scale.

Related Articles

Thorsteinn Halldorsson Senior Cloud Engineer

Senior Cloud Engineer with 25+ years of hands-on experience across the datacenter-to-cloud stack: fiber SAN and disk storage, IBM/Lenovo blade and Dell/HP/Lenovo servers, Hyper-V and VMware clusters, and SQL and Remote Desktop Services (RDS) clusters. Deep in the Microsoft platform — Active Directory, PKI/certificate services, SQL, Power BI, Dynamics 365 Business Central (NAV) and AX (Axapta), Microsoft 365, Entra, and Intune — with a focus on Azure operations, FinOps, and applying AI tools like GitHub Copilot and Claude in real workflows. Writes practical, no-nonsense guides for IT professionals who need to ship real solutions.

Leave a Reply

Your email address will not be published. Required fields are marked *