GitHub Actions OIDC to Azure: Passwordless Deployments Done Right
Executive Snapshot
| 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
- 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
Owneron 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/*.ymlfile 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:
Contributoron a resource group is the default in many guides, but you should prefer purpose-built roles where possible. Deploying only to Azure App Service? UseWebsite Contributor. Pushing images to ACR? UseAcrPush. 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_requestsubjects for any credential with write access to Azure. A contributor with PR creation rights could trigger a deployment. Usepull_request_targetevents 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.
- Navigate to Repository → Settings → Environments → New environment
- Name it
production(must match exactly what you used in the Federated Credential subject and the workflowenvironment:key) - Enable Required reviewers and add your team leads or a dedicated ops team
- Optionally set a deployment branch policy to restrict deployments to
mainonly
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: writemust 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/loginaction sets the auth context for all subsequent steps in the job.
Next Steps
- Audit your existing workflows for
AZURE_CREDENTIALSJSON secrets orclientSecretpatterns — rungh secret listacross your repositories and create a migration backlog. - Implement GitHub Environment protection rules for every environment that has an OIDC credential with write access to Azure production resources.
- Enable Microsoft Entra ID sign-in logs and create a Log Analytics alert for unexpected service principal authentications from the
token.actions.githubusercontent.comissuer — this is your detection layer if a workflow is ever abused. - Review the Azure built-in roles reference and replace
Contributorassignments with purpose-built roles where your deployment scope allows. - 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
- Terraform Remote State in Azure Blob Storage: The Right Way to Bootstrap — secure your state backend before you write a single resource block
- GitHub Actions Environments and Deployment Protection Rules: A Complete Guide — approval gates, branch policies, and required status checks in depth
- Azure Managed Identity vs Service Principal: Choosing the Right Identity for Your Workload — when OIDC is the right tool and when Managed Identity is the better answer
Leave a Reply