Azure DevOps & Identity

GitHub Actions to Azure with OIDC: Passwordless Deploys via Federated Credentials

There is a client secret sitting in your GitHub repository right now. It is called AZURE_CREDENTIALS, it holds the password for a service principal that can deploy to your production subscription, and it expires in two years — except you will forget to rotate it, it will lapse on a Friday, and every pipeline in the org will start failing with AADSTS7000215: Invalid client secret. Worse: that secret is a bearer credential. Anyone who can read it — a compromised Action, a malicious pull request that exfiltrates secrets.*, a leaked backup of your repo — can authenticate to Azure as that principal from anywhere on the internet, for the full lifetime of the secret. This is the single most common way CI/CD pipelines leak cloud access, and the fix has existed since 2021: OpenID Connect (OIDC) federated identity credentials.

OIDC flips the model. Instead of GitHub holding a long-lived Azure password, GitHub Actions mints a short-lived OIDC token at job runtime — a signed JWT that says “this is workflow X, in repo Y, on branch Z” — and Azure’s Microsoft Entra ID (formerly Azure AD) is configured to trust tokens shaped exactly like that and exchange them for a real Azure access token that lives for roughly an hour. No secret is stored anywhere. The trust is pinned to a specific repo, branch or environment, so a token minted by some other repo is rejected at the door. When the job ends, the credential is gone. There is nothing to rotate, nothing to leak, and nothing for an attacker to steal that outlives a single workflow run.

This is a step-by-step implementation guide, and the centerpiece is the hands-on lab: you will build the whole chain end to end — an app registration in Entra ID, a federated identity credential that names your repo and branch, a role assignment that grants exactly the scope you need, and a GitHub Actions workflow that calls azure/login@v2 with id-token: write and deploys a resource — in all three of the portal, the az CLI, and Bicep, with the expected output at every step, validation, and teardown. Along the way you will learn the exact shape of the subject claim, why AADSTS70021 and AADSTS700213 happen and how to read them, and the federated-credentials limits (twenty per app, the subject must match exactly) that bite people on day one. By the end you will never paste an Azure password into a CI system again.

What problem this solves

The legacy pattern is az ad sp create-for-rbac --sdk-auth, which prints a JSON blob containing a clientSecret, and you paste that blob into a GitHub secret named AZURE_CREDENTIALS. It works on the first try, which is exactly why it spread everywhere. The problems show up later and they all stem from one fact: it is a stored, long-lived, bearer secret.

What breaks without OIDC, in production terms:

OIDC removes the stored secret entirely. The credential is minted per job, scoped to the exact workflow context, and expires in minutes. Who hits the legacy pain hardest: any team running Terraform/Bicep deploys, container pushes to Azure Container Registry, or app releases to App Service/AKS from GitHub — which is most teams. The migration is small (one app registration, one federated credential, a three-line change to the workflow) and it is the current Microsoft-recommended pattern. If you maintain CI/CD that touches Azure, this is the upgrade.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already understand a few building blocks. An Entra ID tenant is your identity directory; within it, an app registration defines an application identity and produces a service principal (the enterprise application) that can be granted Azure RBAC. RBAC (role-based access control) grants a principal a role (like Contributor) at a scope (management group, subscription, resource group, or single resource). You should know how to run az in Cloud Shell or locally (az login), and have a GitHub repository where you can edit .github/workflows/*.yml and add environments. Basic familiarity with JWTs (a JWT has claims like iss, aud, sub) helps but the article defines the ones that matter.

This sits at the intersection of the Identity and DevOps tracks. It builds directly on the service-principal and identity model — if app registrations vs enterprise applications are fuzzy, read Entra App Registration vs Enterprise Application Explained first, and the OAuth/OIDC mechanics underneath are covered in OIDC & OAuth2 Flows in Entra ID: Authorization Code + PKCE. On the Azure-side identity choice, federated credentials are a cousin of managed identity, contrasted in Managed Identity: System-Assigned vs User-Assigned Patterns. The thing you typically deploy with this — Bicep — is introduced in Deploy Your First Bicep File From Scratch. And if your old pipeline used a Terraform service principal with a secret, the same OIDC pattern fixes the auth failures described in Terraform on Azure: Provider Authentication 401/403 SPN Troubleshooting.

Here is where the federated-identity approach sits relative to the alternatives, so you can place it before the deep dive:

Auth method from CI to Azure Stored secret? Credential lifetime Bound to workflow context? Recommended today
OIDC federated credential No ~1 hour (per job) Yes (repo/branch/env in subject) Yes — preferred
Service principal + client secret Yes (AZURE_CREDENTIALS) 1–2 years No (portable bearer) Legacy; migrate away
Service principal + certificate Yes (cert/key) Cert validity No Better than secret, still stored
Self-hosted runner + managed identity No Token-lifetime Tied to the runner VM, not the workflow Niche (runners on Azure VMs)
az login interactive / device code No Session N/A — human, not CI Not for automation

Core concepts

Five mental models make every later step obvious.

OIDC is a trust relationship, not a stored credential. With a client secret, GitHub holds a password and presents it to Azure. With OIDC, GitHub holds nothing; instead, Azure is configured to trust an external identity provider — the GitHub Actions OIDC provider at https://token.actions.githubusercontent.com — and to accept tokens it signs, provided the token’s claims match a federated identity credential you registered. The trust is the asset; there is no secret to leak.

The OIDC token is a short-lived, claim-bearing JWT. When a workflow job has permissions: id-token: write, the runner can call GitHub’s token endpoint and receive a signed JWT. Its key claims are the issuer (iss = https://token.actions.githubusercontent.com), the audience (aud, which for Azure must be api://AzureADTokenExchange), and the subject (sub) — a string that encodes which workflow context produced the token, e.g. repo:contoso/shop:ref:refs/heads/main. Azure validates the signature against GitHub’s published JWKS, then matches the iss + sub + aud triple against your registered federated credential.

The subject claim is the security boundary, and it must match exactly. A federated credential pins issuer, subject, and audience. The subject is a literal string (no wildcards in the classic form) — repo:OWNER/REPO:ref:refs/heads/main for a branch, repo:OWNER/REPO:environment:production for a GitHub Environment, repo:OWNER/REPO:pull_request for PRs, repo:OWNER/REPO:ref:refs/tags/v1.0.0 for a tag. If your workflow runs on main but your credential’s subject says develop, Azure returns AADSTS70021: No matching federated identity record found. Exactness is the feature: a token from the wrong branch or repo simply doesn’t match.

The federated credential lives on an identity object, and that identity gets RBAC. You attach the federated credential to either an app registration (creating an associated service principal) or a user-assigned managed identity. That principal is then granted an Azure role at a scope via az role assignment create. The OIDC exchange proves who you are; RBAC decides what you can do. Keep the role least-privilege and the scope tight (a resource group, ideally) — the federated credential authenticates, RBAC authorizes.

The token exchange returns a normal Azure access token. azure/login@v2 (with client-id, tenant-id, subscription-id and no creds secret) does the round trip: it asks GitHub for the OIDC JWT, posts it to Entra’s token endpoint (/oauth2/v2.0/token with grant_type=client_credentials and a client_assertion), and gets back a standard OAuth2 access token. From that point every az command in the job is authenticated exactly as if you’d run az login — the OIDC part is invisible to the rest of the workflow.

The vocabulary in one table

Pin down every moving part before the deep sections. The glossary repeats these for lookup; this is the mental model side by side:

Term One-line definition Where it lives Why it matters
App registration The application identity definition Entra ID → App registrations Holds the federated credential; backs a service principal
Service principal The instance of the app in your tenant Entra ID → Enterprise apps The thing RBAC is granted to
User-assigned MI A standalone managed identity A resource in a resource group Alternative host for the federated credential
Federated credential The trust rule (issuer + subject + audience) On the app reg / MI Matches the GitHub token; max 20 per object
OIDC token Short-lived signed JWT from GitHub Minted per job Proves the workflow context to Azure
subject (sub) Claim naming repo/branch/env Inside the OIDC token Must match the credential exactly
audience (aud) Intended recipient of the token Inside the OIDC token Must be api://AzureADTokenExchange
issuer (iss) Who signed the token Inside the OIDC token https://token.actions.githubusercontent.com
id-token: write Workflow permission to mint a token permissions: in the YAML Without it, no OIDC token exists
azure/login@v2 The Action doing the exchange Marketplace Action Turns the JWT into an Azure token
GitHub Environment A deploy target with rules Repo → Settings → Environments Lets the subject be environment:<name>
Tenant / Client / Subscription ID Directory / app / billing-scope GUIDs Entra + subscription The three IDs azure/login needs

How the OIDC token exchange works, hop by hop

The whole mechanism is one round trip, but every hop has a failure mode, so it pays to know each one precisely.

  1. The workflow declares intent. The job sets permissions: id-token: write (and usually contents: read). Without this, GitHub will not issue an OIDC token, and azure/login fails with “Unable to get ACTIONS_ID_TOKEN_REQUEST_URL”. This permission is job-scoped and defaults to none under the recommended security settings.
  2. GitHub mints the OIDC JWT. At runtime the runner requests a token from GitHub’s internal endpoint. GitHub stamps it with iss = https://token.actions.githubusercontent.com, a sub derived from the trigger (branch/tag/PR/environment), and — because azure/login asks for it — aud = api://AzureADTokenExchange. The token is signed with GitHub’s private key; the matching public keys are published at the issuer’s JWKS endpoint.
  3. azure/login@v2 reads the JWT and calls Entra. The Action POSTs to https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token with grant_type=client_credentials, client_id=<app>, client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer, and client_assertion=<the GitHub JWT>. The GitHub token is the client assertion — that is what replaces the client secret.
  4. Entra validates the token against the federated credential. Entra fetches GitHub’s JWKS, verifies the signature, then looks for a federated identity credential on the app whose issuer, subject, and audience all match the token’s iss, sub, and aud. A mismatch on any of the three → AADSTS70021. Two credentials that both match → AADSTS700213.
  5. Entra issues an Azure access token. On a match, Entra returns a normal bearer access token (lifetime around 60–90 minutes) for the requested resource (https://management.azure.com). azure/login stores it; the rest of the job is authenticated.
  6. az/Bicep commands run under that token. az deployment group create, az acr login, az webapp deploy — all use the cached token. RBAC on the service principal decides what succeeds. When the job ends, the token is discarded.

The claims you can pin in a federated credential, and what each constrains:

Token claim Federated-credential field Example value What it locks down
iss (issuer) Issuer https://token.actions.githubusercontent.com Only GitHub-signed tokens are accepted
aud (audience) Audience api://AzureADTokenExchange The token was minted for Azure, not reused elsewhere
sub (subject) Subject repo:contoso/shop:ref:refs/heads/main The exact repo + trigger context allowed
(n/a — name) Name gh-shop-main A label for you; not validated
(n/a — description) Description “prod deploys from main” Documentation only

A reading note that saves hours: the subject is opaque to Azure. Entra does string-equality on it; it does not understand “branch” or “environment”. So you must produce the exact string GitHub will put in the token. The next section is the canonical map.

The inputs azure/login@v2 takes, and which ones switch it from secret-mode to OIDC-mode:

azure/login@v2 input OIDC mode Legacy secret mode Notes
client-id Required The app/MI client ID (a GitHub variable, not a secret)
tenant-id Required Your Entra directory ID
subscription-id Required Sets the active subscription for later az calls
creds Must be absent Required (the JSON blob) Presence of creds = the old secret path
enable-AzPSSession Optional Optional Also authenticate Azure PowerShell, not just az
audience Defaults to api://AzureADTokenExchange n/a Don’t override unless you truly need to
allow-no-subscriptions Optional Optional For tenant-level (no subscription) operations

Constructing the subject claim correctly

This is where 80% of first-time failures live. The subject your federated credential stores must equal, character for character, the sub GitHub stamps into the token for the trigger you run on. Get the trigger and the string aligned.

Trigger you run on Subject string to register Notes / gotcha
Push/deploy on a branch repo:OWNER/REPO:ref:refs/heads/BRANCH main and develop need separate credentials
A specific tag repo:OWNER/REPO:ref:refs/tags/TAG e.g. refs/tags/v1.2.0; tags need their own credential
Any pull request repo:OWNER/REPO:pull_request One credential covers all PRs (no branch in the subject)
A GitHub Environment repo:OWNER/REPO:environment:ENVNAME Preferred for prod — pairs with environment protection rules
Reusable workflow caller repo:OWNER/REPO:ref:refs/heads/BRANCH (caller context) The subject reflects the calling repo/branch

Three rules that prevent the classic mistakes:

If you are ever unsure what GitHub will actually emit, dump the token’s claims in a throwaway job (decode only — never print it to a public log in a way that leaks it):

# Diagnostic ONLY — prints the SUBJECT so you can copy it into the federated credential.
# Remove after you have the value. Do not leave token-dumping in a shared workflow.
- name: Show OIDC subject
  run: |
    TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
      "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" | jq -r '.value')
    echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{iss, aud, sub}'

The flexible-claims form (a newer federated-credential type using claimsMatchingExpression with wildcards like repo:contoso/shop:ref:refs/heads/release/*) exists for orgs that need pattern matching; it is more advanced and not required for the common case, so this guide uses the exact-subject form throughout and notes the wildcard option where relevant.

App registration vs user-assigned managed identity

You can host the federated credential on either object. Both work with azure/login@v2. The choice is mostly about where the identity “lives” and how you manage it.

Dimension App registration (+ service principal) User-assigned managed identity
Where it lives Entra ID directory object An Azure resource in a resource group
Created with az ad app create + az ad sp create az identity create
Federated cred command az ad app federated-credential create az identity federated-credential create
Lifecycle / cleanup Deleted from Entra; not tied to a subscription Deleted with its resource group
RBAC target The service principal’s object/app id The MI’s principal id
Multi-tenant scenarios Supported Single-tenant (lives in one subscription)
Who can create it Needs directory permission (App Developer+) Needs RBAC on the resource group only
Best for Cross-subscription, central identity team Self-service teams, infra-as-code per env

Practical guidance: if your platform team centrally owns identities and you deploy across many subscriptions, the app registration is the natural home. If each team or environment owns its own resource group and you want the identity to be created and destroyed with the infra (and you’d rather not need directory-level permissions), the user-assigned managed identity is cleaner — az identity create needs only resource-group RBAC, no Entra app-admin role. This guide does the lab with an app registration (the most common GitHub-Actions case) and gives the managed-identity equivalents in a dedicated step so you can pick either.

RBAC: granting the federated principal exactly enough

The OIDC exchange only authenticates. Without a role assignment, your authenticated principal can do nothing — az deployment group create returns AuthorizationFailed. The mistake people make is over-granting: Owner at the subscription, “to be safe”. Don’t. Match the role to the job.

Deploy task the workflow does Least-privilege role Tightest sane scope
az deployment group create (ARM/Bicep into one RG) Contributor The target resource group
Push an image to ACR AcrPush The registry resource
Deploy app code to App Service Website Contributor The web app / RG
kubectl/Helm to AKS Azure Kubernetes Service Cluster User Role (+ in-cluster RBAC) The cluster
Assign roles as part of deploy User Access Administrator or Owner Avoid if possible; scope to RG
Read-only plan / what-if Reader The resource group

Why scope to a resource group, not the subscription: a leaked-but-short-lived token (or a logic bug in the workflow) can only touch what the role+scope allow. A Contributor on rg-shop-prod cannot delete rg-payments-prod. If you must deploy across resource groups, grant at each RG rather than jumping to subscription scope. And avoid Owner/User Access Administrator unless the deploy genuinely creates role assignments — those roles let the principal grant itself more, which defeats least privilege.

Architecture at a glance

Read the diagram left to right and you are reading the token’s life. On the far left, a developer pushes to main (or opens a PR, or triggers a release), which starts a GitHub Actions workflow run. The job declares permissions: id-token: write, so the runner asks the GitHub OIDC provider for a signed JWT whose subject encodes repo:OWNER/REPO:ref:refs/heads/main and whose audience is api://AzureADTokenExchange. That JWT travels to Microsoft Entra ID, where the azure/login step presents it as a client assertion to the token endpoint. Entra verifies GitHub’s signature, then checks the token’s issuer + subject + audience against the federated identity credential stored on your app registration. On an exact match — and only then — Entra hands back a short-lived Azure access token.

From that point the flow enters the Azure subscription. The service principal, carrying its newly-minted token, runs az deployment group create against the resource group, where its Contributor role assignment (scoped to that RG, nothing wider) authorizes the create. The deployment lands real resources — say an App Service plus its Key Vault — and the job finishes. The numbered badges mark the four hops that fail in practice: a missing id-token: write (no token is ever minted), a subject mismatch (AADSTS70021 at Entra), a missing role assignment (AuthorizationFailed at the subscription), and a wrong audience (AADSTS700212). Trace any failure to its badge and you know exactly which hop lied.

Left-to-right architecture of GitHub Actions deploying to Azure with OIDC: a developer push triggers a GitHub Actions workflow with id-token write permission, the GitHub OIDC provider mints a short-lived JWT with a repo/branch subject and api://AzureADTokenExchange audience, the token is exchanged at Microsoft Entra ID against a federated identity credential on an app registration, Entra returns a one-hour Azure access token, and the service principal with a resource-group-scoped Contributor role runs az deployment group create to provision an App Service and Key Vault — with numbered badges on the four failure hops: missing id-token permission, subject mismatch (AADSTS70021), missing role assignment (AuthorizationFailed), and wrong audience (AADSTS700212).

Real-world scenario

Contoso Retail ran fourteen GitHub repositories that deployed to Azure — storefronts, internal APIs, a data pipeline, infrastructure modules. Every repo carried the same AZURE_CREDENTIALS secret: a single Owner-scoped service principal at the subscription, with a client secret set to the two-year maximum. It had been created during a hackathon eighteen months earlier and copied everywhere because it “just worked.”

Two things happened in the same quarter. First, the secret hit its expiry. On a Tuesday morning, twelve pipelines failed within an hour, all with AADSTS7000215: Invalid client secret is provided. The platform team scrambled, minted a fresh secret, and — because there was no automation — hand-updated the secret in fourteen repositories over an afternoon, with two typos that caused a further day of “why is this repo still failing.” Second, a security review flagged that a pull_request workflow in a public-facing repo echoed environment variables for debugging, and the reviewer demonstrated that a malicious PR could have printed secrets.AZURE_CREDENTIALS to the build log. The same subscription-Owner secret was, in effect, one careless PR away from full subscription compromise.

The remediation was an OIDC migration, run over a sprint. The platform team created one user-assigned managed identity per environment (mi-deploy-dev, mi-deploy-prod) rather than one giant principal. Each got federated credentials scoped to GitHub Environments: repo:contoso/<repo>:environment:dev and :environment:prod. The prod environment was given required reviewers in GitHub, so a production deploy now needs a human approval before the OIDC token is even minted. RBAC was tightened from subscription-Owner to Contributor on the specific resource group each app deployed into; the data pipeline’s identity got AcrPush on its registry and nothing else.

The workflow change per repo was three lines: add permissions: id-token: write, swap the azure/login step from creds: ${{ secrets.AZURE_CREDENTIALS }} to client-id + tenant-id + subscription-id, and delete the secret. The numbers afterward: zero stored Azure secrets across all fourteen repos; nothing to rotate (no more expiry outages); blast radius cut from “subscription Owner” to “one resource group, for one hour”; and full provenance — the Entra sign-in logs now show exactly which repo and environment requested each token. The one snag during rollout was the data-pipeline repo, which deployed from both main and a release/* branch family: the team initially registered only refs/heads/main and the release deploys failed with AADSTS70021. They added a second federated credential for the release branch (later consolidated onto the environment subject), and the migration was done — org-wide stored Azure secrets: zero.

Advantages and disadvantages

Advantages Disadvantages
No stored secret — nothing to leak or exfiltrate Slightly more upfront setup than pasting one JSON blob
No rotation — credential is per-job and self-expiring Subject must match exactly; easy to mis-type on day one
Bound to workflow context (repo/branch/env) Max 20 federated credentials per identity object
Short-lived token (~1 hour) limits blast radius No classic wildcards in subject (need flexible-claims for patterns)
Full provenance in Entra sign-in logs Requires id-token: write and GitHub-hosted-style OIDC support
Pairs with GitHub Environment approvals for prod gating Multiple matching credentials → AADSTS700213 if careless
Works for app reg or user-assigned managed identity Token-dump debugging must be handled carefully (don’t leak it)

When the advantages dominate: essentially always for GitHub-Actions-to-Azure CI/CD. The only places the legacy secret lingers defensibly are systems that genuinely cannot present an OIDC token (some third-party CI without an OIDC provider) or air-gapped scenarios. For everything running in GitHub Actions, OIDC is strictly better. The disadvantages are real but small: the setup is a one-time cost, and the “exact subject” rule that trips people initially is the very thing that makes the trust safe. The 20-credential cap pushes you toward environment-scoped subjects (one per environment) rather than one-per-branch sprawl, which is the better design anyway.

Hands-on lab

This is the centerpiece. You will build the full chain — app registration, federated credential, RBAC, and a working deploy workflow — three ways: the Azure portal, the az CLI, and Bicep. Do the CLI path first (it is the fastest and most reproducible), then the portal walkthrough so you can recognize the UI, then the Bicep version for infra-as-code. Each path ends in the same place: a green workflow run that deploys a resource with no stored secret.

Lab prerequisites and naming

Item Value used in this lab Your value
GitHub repo OWNER/REPO (e.g. contoso/oidc-demo) your repo
Branch to deploy from main your branch
Resource group rg-oidc-demo pick one
Location centralindia nearest region
App registration name gh-oidc-demo pick one
Federated credential name gh-oidc-demo-main pick one
Audience (fixed) api://AzureADTokenExchange (do not change)
Role Contributor least-privilege

You need: an Azure subscription where you can create resource groups and assign roles (you must be Owner or User Access Administrator on the scope to grant RBAC), permission to create an app registration in your tenant (Application Developer role or the tenant allows user app registration), and a GitHub repo you can edit. All Azure commands run in Cloud Shell or a local az session (az login).

Path A — the az CLI (do this first)

Step 1 — Set variables and create the resource group.

# Adjust these four lines, then paste the rest as-is.
GH_ORG="contoso"            # GitHub org/user
GH_REPO="oidc-demo"         # GitHub repository name
RG="rg-oidc-demo"
LOCATION="centralindia"

SUBSCRIPTION_ID=$(az account show --query id -o tsv)
TENANT_ID=$(az account show --query tenantId -o tsv)
az group create --name "$RG" --location "$LOCATION" -o table

Expected output: a table row showing rg-oidc-demo with provisioningState = Succeeded.

Step 2 — Create the app registration and its service principal.

APP_NAME="gh-oidc-demo"
# Create the app registration; capture its appId (this becomes AZURE_CLIENT_ID).
CLIENT_ID=$(az ad app create --display-name "$APP_NAME" --query appId -o tsv)
# Create the service principal for that app (the object RBAC is granted to).
az ad sp create --id "$CLIENT_ID" -o none
echo "CLIENT_ID=$CLIENT_ID"
echo "TENANT_ID=$TENANT_ID"
echo "SUBSCRIPTION_ID=$SUBSCRIPTION_ID"

Expected output: a GUID for CLIENT_ID. Copy all three IDs — they go into GitHub as variables (not secrets) in Step 6. Note there is no secret anywhere in this command; that is the whole point.

Step 3 — Add the federated identity credential. This is the trust rule. The subject must match the token GitHub will emit for a push to main.

SUBJECT="repo:${GH_ORG}/${GH_REPO}:ref:refs/heads/main"

az ad app federated-credential create --id "$CLIENT_ID" --parameters "{
  \"name\": \"gh-oidc-demo-main\",
  \"issuer\": \"https://token.actions.githubusercontent.com\",
  \"subject\": \"${SUBJECT}\",
  \"audiences\": [\"api://AzureADTokenExchange\"],
  \"description\": \"Deploys from main via GitHub Actions OIDC\"
}"

Expected output: JSON echoing the credential with your subject, issuer, and audiences. Validate it landed:

az ad app federated-credential list --id "$CLIENT_ID" \
  --query "[].{name:name, subject:subject, audience:audiences[0]}" -o table

You should see one row with subject = repo:contoso/oidc-demo:ref:refs/heads/main and audience = api://AzureADTokenExchange. If the subject here doesn’t match what your workflow triggers on, you will get AADSTS70021 later — fix it now.

Step 4 — Grant RBAC at the resource-group scope (least privilege).

SCOPE="/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RG}"
az role assignment create \
  --assignee "$CLIENT_ID" \
  --role "Contributor" \
  --scope "$SCOPE" -o table

Expected output: a table row showing the role assignment with roleDefinitionName = Contributor and your scope. Validate:

az role assignment list --assignee "$CLIENT_ID" --scope "$SCOPE" \
  --query "[].{role:roleDefinitionName, scope:scope}" -o table

One row, Contributor, scoped to rg-oidc-demo. Not the subscription — the resource group.

Step 5 — Add the deploy artifact to your repo. Commit a tiny Bicep file the workflow will deploy. This proves the token works against ARM.

mkdir -p infra
cat > infra/storage.bicep <<'EOF'
// Minimal, free-to-deploy-and-delete resource to prove the OIDC deploy works.
param location string = resourceGroup().location
@minLength(3) @maxLength(24)
param storageName string = 'oidc${uniqueString(resourceGroup().id)}'

resource sa 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: storageName
  location: location
  sku: { name: 'Standard_LRS' }
  kind: 'StorageV2'
  properties: { minimumTlsVersion: 'TLS1_2', allowBlobPublicAccess: false }
}

output storageAccount string = sa.name
EOF

(Storage account names must be globally unique and lowercase; uniqueString handles that.)

Step 6 — Set the three IDs as GitHub repository variables. These are not secrets — a client ID, tenant ID, and subscription ID are identifiers, not credentials, and there is no password to hide. Using gh:

gh variable set AZURE_CLIENT_ID --body "$CLIENT_ID" --repo "${GH_ORG}/${GH_REPO}"
gh variable set AZURE_TENANT_ID --body "$TENANT_ID" --repo "${GH_ORG}/${GH_REPO}"
gh variable set AZURE_SUBSCRIPTION_ID --body "$SUBSCRIPTION_ID" --repo "${GH_ORG}/${GH_REPO}"

(Or in the UI: Settings → Secrets and variables → Actions → Variables → New repository variable.) Expected: three variables listed under the repo’s Actions variables.

Step 7 — Create the workflow. This is the passwordless deploy. Note the two critical lines: permissions: id-token: write and an azure/login@v2 step with no creds.

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

on:
  push:
    branches: [ main ]
  workflow_dispatch:

permissions:
  id-token: write     # REQUIRED — lets the job mint an OIDC token
  contents: read      # to checkout the repo

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

      - name: Azure login (passwordless, via OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
          # NOTE: no `creds:` — that's the legacy secret path we are removing.

      - name: Deploy Bicep to the resource group
        uses: azure/cli@v2
        with:
          azcliversion: latest
          inlineScript: |
            az deployment group create \
              --resource-group rg-oidc-demo \
              --template-file infra/storage.bicep \
              --query "properties.outputs.storageAccount.value" -o tsv

Step 8 — Run it and read the output. Commit and push to main (or trigger via Actions → Deploy to Azure (OIDC) → Run workflow).

git add infra/storage.bicep .github/workflows/deploy.yml
git commit -m "OIDC passwordless deploy"
git push origin main

Expected output in the run log: the Azure login step prints Login successful (and that it used OIDC/federated token, not a secret). The Deploy step ends by printing the new storage account name (e.g. oidc7k3f...). In the Azure portal, rg-oidc-demo now contains a storage account. You just deployed to Azure with zero stored secrets.

Step 9 — Validate the trust end to end. Confirm the resource exists and that the sign-in came through the federated credential:

az resource list -g "$RG" --query "[].{name:name, type:type}" -o table
# Optional: in Entra, Sign-in logs → Service principal sign-ins shows the app
# authenticating with credential type 'Federated Identity Credential'.

Path B — the Azure portal (recognize the UI)

The portal path produces the same three objects. Do it once so you can navigate and troubleshoot in the UI.

# Portal navigation What you do Expected result
1 Microsoft Entra ID → App registrations → New registration Name gh-oidc-demo; Single tenant; no redirect URI; Register App created; note Application (client) ID and Directory (tenant) ID
2 The app → Certificates & secrets → Federated credentials → Add credential Scenario: GitHub Actions deploying Azure resources A guided form appears
3 Fill the federated form Organization contoso, Repository oidc-demo, Entity Branch, Branch main; Name gh-oidc-demo-main Subject auto-builds to repo:contoso/oidc-demo:ref:refs/heads/main
4 Save Credential listed; audience shows api://AzureADTokenExchange
5 Resource group rg-oidc-demo → Access control (IAM) → Add → Add role assignment Role Contributor; Members → User, group, or service principal → select gh-oidc-demo Role assignment created at RG scope
6 Subscriptions → your sub → Overview Copy the Subscription ID You now have all three IDs
7 GitHub repo → Settings → Secrets and variables → Actions → Variables Add AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID Three variables set
8 Add .github/workflows/deploy.yml (Step 7 above) and push Workflow runs green; resource deployed

The portal’s “GitHub Actions deploying Azure resources” scenario is the friendliest entry point: it builds the subject string for you from dropdowns, which eliminates the most common typo. If you choose Entity = Environment instead of Branch, it asks for the environment name and emits repo:OWNER/REPO:environment:NAME — use that for production with environment protection rules.

Path C — Bicep (infra-as-code)

For a repeatable, reviewable definition, declare the app registration’s federated credential and the role assignment as code. Two notes: classic app registrations are Microsoft Graph objects, which Bicep manages via the Graph provider (Microsoft.Graph/..., in preview) — so for a pure-ARM/Bicep flow the cleaner, fully-supported path is a user-assigned managed identity, whose federated credential and role assignment are first-class ARM resources. This is also why many teams pick the managed-identity host. Here is that Bicep:

// main.bicep — user-assigned MI + federated credential + RG-scoped Contributor.
// Deploy at resource-group scope: az deployment group create -g rg-oidc-demo -f main.bicep
param location string = resourceGroup().location
param ghOrg string = 'contoso'
param ghRepo string = 'oidc-demo'
param branch string = 'main'

resource mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-07-31-preview' = {
  name: 'mi-gh-oidc-demo'
  location: location
}

// The federated credential: trust GitHub's OIDC for this repo + branch.
resource fic 'Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials@2023-07-31-preview' = {
  parent: mi
  name: 'gh-oidc-demo-main'
  properties: {
    issuer: 'https://token.actions.githubusercontent.com'
    subject: 'repo:${ghOrg}/${ghRepo}:ref:refs/heads/${branch}'
    audiences: [ 'api://AzureADTokenExchange' ]
  }
}

// Grant Contributor on THIS resource group to the managed identity.
var contributorRoleId = 'b24988ac-6180-42a0-ab88-20f7382dd24c' // Contributor (built-in)
resource ra 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(resourceGroup().id, mi.id, contributorRoleId)
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', contributorRoleId)
    principalId: mi.properties.principalId
    principalType: 'ServicePrincipal'
  }
}

output clientId string = mi.properties.clientId       // → AZURE_CLIENT_ID
output principalId string = mi.properties.principalId

Deploy and read the outputs:

az deployment group create -g rg-oidc-demo -f main.bicep \
  --query "properties.outputs.clientId.value" -o tsv

Expected output: the managed identity’s client ID. Put that into AZURE_CLIENT_ID (the tenant and subscription IDs are unchanged) and use the same workflow from Step 7 — azure/login@v2 does not care whether the client ID belongs to an app registration or a managed identity. The federated-credential equivalent for the CLI managed-identity path, if you prefer not to use Bicep:

az identity create -g rg-oidc-demo -n mi-gh-oidc-demo
MI_CLIENT_ID=$(az identity show -g rg-oidc-demo -n mi-gh-oidc-demo --query clientId -o tsv)
MI_PRINCIPAL_ID=$(az identity show -g rg-oidc-demo -n mi-gh-oidc-demo --query principalId -o tsv)
az identity federated-credential create -g rg-oidc-demo --identity-name mi-gh-oidc-demo \
  --name gh-oidc-demo-main \
  --issuer "https://token.actions.githubusercontent.com" \
  --subject "repo:${GH_ORG}/${GH_REPO}:ref:refs/heads/main" \
  --audiences "api://AzureADTokenExchange"
az role assignment create --assignee-object-id "$MI_PRINCIPAL_ID" \
  --assignee-principal-type ServicePrincipal \
  --role Contributor --scope "/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RG}"

Validation checkpoints

Before teardown, confirm each link in the chain is sound. If a row fails, the failure mode tells you exactly which object is wrong:

Checkpoint Command / where to look Pass criterion If it fails
Identity exists az ad sp show --id $CLIENT_ID (or az identity show ...) Returns the principal Re-run create; check you copied the right appId/clientId
Federated cred present az ad app federated-credential list --id $CLIENT_ID Subject = your trigger context Add/fix the credential (Step 3) — guards against AADSTS70021
RBAC granted az role assignment list --assignee $CLIENT_ID --scope $SCOPE One Contributor row at the RG Grant the role (Step 4) — guards against AuthorizationFailed
GitHub variables set Repo → Settings → Variables Three AZURE_* variables Re-set them (Step 6)
Login step Workflow run → Azure login log Login successful (federated) Check id-token: write + the three IDs
Deploy succeeded az resource list -g $RG The storage account appears Read the deploy step’s AADSTS/RBAC error and map it above

Teardown

Remove everything so you are not billed and no stale identity lingers:

# 1) Delete the resource group (storage account, and the MI if you used Path C)
az group delete --name rg-oidc-demo --yes --no-wait

# 2) If you used Path A (app registration), delete it from Entra
az ad app delete --id "$CLIENT_ID"

# 3) In GitHub: delete the three Actions variables and the workflow file
gh variable delete AZURE_CLIENT_ID --repo "${GH_ORG}/${GH_REPO}"
gh variable delete AZURE_TENANT_ID --repo "${GH_ORG}/${GH_REPO}"
gh variable delete AZURE_SUBSCRIPTION_ID --repo "${GH_ORG}/${GH_REPO}"

Expected: the resource group enters Deleting; az ad app list --display-name gh-oidc-demo returns empty after the delete.

Common mistakes & troubleshooting

These are the failures you will actually hit, in rough order of frequency. Each is symptom → root cause → how to confirm → fix.

# Symptom (error / behaviour) Root cause Confirm with Fix
1 Unable to get ACTIONS_ID_TOKEN_REQUEST_URL / “Please make sure to give write permissions to id-token” permissions: id-token: write missing Read the failing azure/login log line Add permissions: { id-token: write, contents: read } to the job
2 AADSTS70021: No matching federated identity record found for presented assertion subject Federated-credential subject ≠ the token’s sub Compare credential subject vs the trigger; dump the JWT subject (diagnostic job) Register a credential whose subject exactly matches (branch/env/tag)
3 AADSTS700213: No matching federated identity record found ... multiple matching Two+ credentials match the same token List federated creds; look for overlapping subjects Remove the duplicate/overlapping credential
4 AADSTS700212: No matching federated identity record found ... audience Audience mismatch (not api://AzureADTokenExchange) Inspect the credential’s audiences Set audience to api://AzureADTokenExchange (and let azure/login request it)
5 AuthorizationFailed ... does not have authorization to perform action No role assignment, or wrong scope/role az role assignment list --assignee <clientId> Grant Contributor (or the right role) at the target scope
6 Login failed with Error: Az.Accounts ... unable to get token / login step hangs Wrong client-id/tenant-id, or app reg has no service principal Verify the three IDs; az ad sp show --id <clientId> Use the correct IDs; run az ad sp create --id <appId>
7 AADSTS7000215: Invalid client secret is provided Workflow still uses the old creds: secret path Check the azure/login step for creds: Remove creds:; switch to client-id/tenant-id/subscription-id
8 AADSTS900561: The endpoint only accepts POST requests Malformed token-endpoint call (often a copy-paste of a custom curl) Compare with azure/login@v2 defaults Use azure/login@v2 as-is; don’t hand-roll the token POST
9 Works on main, fails on a tag/PR/release branch Subject only registered for one ref type Trigger context vs credential subject Add a credential per ref type (or use environment subject)
10 Deploy works locally but PR runs can’t get a token Fork PRs don’t get id-token write by default (security) Note the run is from a fork Use pull_request from same-repo branches, or deploy on push/environment only
11 Cannot add federated identity credential / “limit reached” More than 20 federated credentials on the object az ad app federated-credential list --id <id> Consolidate onto environment-scoped subjects; you get max 20
12 Token requested but aud is api://AzureADTokenExchange ignored / default audience azure/login not asked for the Azure audience (rare custom setups) Decode aud in the diagnostic job Let azure/login@v2 set the audience; don’t override it

For fast lookup mid-incident, the AADSTS codes you will actually see from the token exchange, decoded:

Code Where it surfaces Literal meaning One-line fix
AADSTS70021 azure/login step No federated record matched the subject Register a credential whose subject equals the token sub exactly
AADSTS700212 azure/login step No match on audience Use api://AzureADTokenExchange
AADSTS700213 azure/login step Multiple credentials matched Remove the overlapping/duplicate credential
AADSTS7000215 azure/login step Invalid client secret You’re still on the legacy creds: path — remove it
AADSTS900561 azure/login step Endpoint only accepts POST Don’t hand-roll the token call; use azure/login@v2
AADSTS50034 / 700016 azure/login step App/principal not found in tenant Wrong client-id/tenant-id; or run az ad sp create --id <appId>
AuthorizationFailed (not AADSTS) A later az step Authenticated but no RBAC Grant the least-privilege role at the target scope

The three highest-value clarifications:

Distinction The trap How to tell them apart
70021 (subject) vs 700212 (audience) vs 700213 (multiple) All are “no matching federated identity record” — the suffix differs Read the suffix and the trailing clause: subject, audience, or multiple matching
Auth failure (Entra) vs authz failure (subscription) Both look like “the deploy didn’t work” AADSTS* = the login step (identity); AuthorizationFailed = a later az step (RBAC)
OIDC not configured vs old secret still present A half-migrated workflow can fail either way id-token/70021 errors = OIDC path; 7000215 = the legacy creds: secret is still wired up

Reading AADSTS70021 precisely. The full error usually includes both the presented subject and (sometimes) the assertion details. The single most reliable fix is to run the diagnostic job from the subject section, copy the exact sub string GitHub emitted, and paste it verbatim into a new federated credential. Ninety percent of 70021 cases are refs/heads/main registered while the workflow ran on a differently-named branch, an environment deploy where the credential was branch-scoped, or a tag build with no tag credential.

Best practices

  1. Prefer GitHub Environment-scoped subjects for production. repo:OWNER/REPO:environment:production lets you attach required reviewers and wait timers in GitHub, so a human gates the deploy and the Azure trust is to the environment, not a branch.
  2. One identity per environment, least-privilege per scope. mi-deploy-dev with Contributor on rg-app-dev; mi-deploy-prod on rg-app-prod. Never one subscription-Owner principal shared across everything.
  3. Scope RBAC to the resource group, not the subscription. Grant exactly the role the workflow needs (Contributor, AcrPush, Website Contributor) at the tightest scope.
  4. Set permissions: explicitly and minimally at the job level. id-token: write and contents: read — nothing more. Don’t grant id-token: write to jobs that don’t deploy.
  5. Pin the action version (azure/login@v2, ideally to a SHA in high-security repos) so a compromised tag can’t change auth behaviour under you.
  6. Use repository/environment variables for the three IDs, not secrets. Client/tenant/subscription IDs are identifiers; treating them as secrets adds friction and implies a confidentiality they don’t have.
  7. Never leave token-dumping in a shared workflow. Use the diagnostic subject-dump once, then remove it; printing the raw OIDC JWT to logs is a (short-lived) credential leak.
  8. Branch-protect the branches whose subjects you trust. If refs/heads/main is a trusted subject, require PR review and status checks on main so nobody can push deploy-capable code unreviewed.
  9. Delete the old AZURE_CREDENTIALS secret after migrating. A dormant long-lived secret is still a liability; remove it from every repo and revoke the underlying SP credential.
  10. Document each credential’s purpose in its description; keep a registry mapping repo/branch/environment to identity and scope.
  11. Watch the 20-credential cap. It’s a design smell if you approach it — consolidate onto environment subjects instead of one-per-branch.
  12. Audit with Entra sign-in logs. Service-principal sign-ins now carry the credential type and the calling context; alert on unexpected subjects or failures.

For reference, the workflow permissions: you actually need for an OIDC deploy (grant the minimum; everything unlisted defaults to none under the recommended setting):

Permission Set to Why Risk if over-granted
id-token write Mandatory — mints the OIDC JWT None to add; required
contents read Checkout the repo write lets the job push to the repo
packages only if pushing to GHCR Container publish Avoid unless needed
pull-requests only if commenting on PRs e.g. what-if comments write can edit PRs
everything else (omit) Defaults to none Broad tokens widen blast radius

Security notes

The security win is structural: there is no long-lived bearer secret to steal, so the most common CI/CD credential-leak vector simply disappears. But OIDC is only as tight as the trust you register, so:

The identity model here complements broader Entra access governance — pair it with the access-review and conditional-access posture in Conditional Access: Deploy Baseline Policies in Report-Only for the human side of the same tenant.

Cost & sizing

The headline: OIDC federated identity is free. App registrations, service principals, user-assigned managed identities, and federated credentials carry no Azure charge. The token exchange itself is free. You pay only for what the workflow deploys. So the cost story is really about the avoided cost of the legacy approach and the resources your pipeline creates.

Cost driver Charge Notes
App registration / service principal Free Directory object; no cost
User-assigned managed identity Free An Azure resource, but not billed
Federated credential Free Up to 20 per identity object
OIDC token exchange Free No per-call charge
GitHub Actions minutes Per your GitHub plan Public repos free; private repos have included minutes
What you actually deploy Normal Azure rates The storage account in the lab is < ₹5/month at Standard_LRS; delete it in teardown
Avoided: rotation outages (savings) No more org-wide failures when a secret expires

Sizing notes that matter:

For the surrounding spend hygiene (budgets, alerts on whatever the pipeline provisions), the foundations are in Azure Cost Management: Budgets & Alerts in the First 30 Days.

Interview & exam questions

These map to AZ-400 (DevOps Engineer Expert) — secure pipeline auth, service connections, secrets management — and SC-300 (Identity & Access Administrator) — workload identities and federation. AZ-104 touches the RBAC and app-registration basics.

Q1. What is an OIDC federated identity credential, and why is it preferred over a client secret for GitHub Actions? It is a trust rule on an Entra app/managed identity that accepts a short-lived OIDC token from GitHub (matched on issuer, subject, audience) and exchanges it for an Azure access token. It is preferred because no long-lived secret is stored, nothing needs rotation, and the trust is bound to a specific workflow context — eliminating the leak/rotation problems of AZURE_CREDENTIALS.

Q2. Name the three claims a federated credential matches and their required values for GitHub→Azure. issuer = https://token.actions.githubusercontent.com; subject = the workflow context, e.g. repo:OWNER/REPO:ref:refs/heads/main; audience = api://AzureADTokenExchange. All three must match the presented token or Entra returns an AADSTS7002xx “no matching record” error.

Q3. Your workflow runs on main but azure/login fails with AADSTS70021. What happened? The federated credential’s subject does not match the token’s sub. Most often the credential is registered for a different branch/environment/tag. Fix by registering a credential whose subject exactly equals what GitHub emits for that trigger (e.g. dump the JWT subject and copy it).

Q4. What workflow permission is mandatory for OIDC, and what happens without it? permissions: id-token: write at the job (or workflow) level. Without it, GitHub will not issue an OIDC token and azure/login fails with “Unable to get ACTIONS_ID_TOKEN_REQUEST_URL”.

Q5. App registration vs user-assigned managed identity as the federated-credential host — when do you pick each? App registration for central, cross-subscription identity ownership (and multi-tenant). User-assigned managed identity when you want the identity created/destroyed with a resource group, manage it purely as ARM/Bicep, and avoid needing Entra app-admin permissions. Both work identically with azure/login.

Q6. After a successful OIDC login, the deploy fails with AuthorizationFailed. Auth or authorization problem? Authorization. The login (authentication) succeeded — the identity is valid — but it lacks an RBAC role at the target scope. Grant the least-privilege role (e.g. Contributor) at the resource group.

Q7. How do GitHub Environments improve the security of an OIDC deploy? The subject becomes repo:OWNER/REPO:environment:NAME, decoupled from branch. You can attach required reviewers, wait timers, and branch restrictions to the environment, so a human approval gates production and the Azure trust is to that environment rather than to a pushable branch.

Q8. What is the maximum number of federated credentials per identity, and how should that shape your design? Twenty per app registration or managed identity. It pushes you toward environment-scoped subjects (one per environment) rather than registering one credential per branch, which is both within the limit and the cleaner design.

Q9. A pull-request workflow from a fork can’t obtain an Azure token. Why, and is that a bug? Not a bug — it’s by design. GitHub does not grant id-token: write (and restricts secrets) to fork-triggered PR workflows, so a malicious fork can’t mint your Azure token. Deploy on push to protected branches or via environments with approvals instead.

Q10. Which audience value must the federated credential use, and what does it protect against? api://AzureADTokenExchange. It binds the token to Azure as the intended recipient, so a token minted for a different audience cannot be replayed against Entra’s token-exchange endpoint.

Q11. You see AADSTS700213 — what’s the cause? Multiple federated credentials on the identity match the same presented token (overlapping subjects). Remove the duplicate/overlapping credential so exactly one matches.

Q12. How would you migrate an existing repo from AZURE_CREDENTIALS to OIDC with zero downtime? Create the app reg/MI + federated credential + RBAC alongside the existing setup; add id-token: write and switch the azure/login step to client-id/tenant-id/subscription-id; run a test deploy; once green, delete the AZURE_CREDENTIALS secret and revoke the old SP credential.

Quick check

  1. What three claims does Entra match a GitHub OIDC token against, and what must the audience be?
  2. Your deploy job has no permissions: block. Will azure/login@v2 (OIDC mode) work? Why or why not?
  3. You deploy from both main and release/2026 and only registered a credential for main. What error do release deploys throw and how do you fix it?
  4. After login succeeds, az deployment group create returns AuthorizationFailed. Is the federated credential wrong? What do you fix?
  5. Why are the client ID, tenant ID, and subscription ID stored as GitHub variables rather than secrets?

Answers

  1. issuer (https://token.actions.githubusercontent.com), subject (the repo:... context), and audience — which must be api://AzureADTokenExchange.
  2. No. Under the recommended security defaults the token permissions default to none, so without id-token: write GitHub won’t issue the OIDC token and azure/login fails to get ACTIONS_ID_TOKEN_REQUEST_URL. Add permissions: { id-token: write, contents: read }.
  3. AADSTS70021: No matching federated identity record found for the release runs, because no credential’s subject matches repo:OWNER/REPO:ref:refs/heads/release/2026. Fix by adding a second federated credential for that ref (or move to an environment-scoped subject).
  4. No — the federated credential is fine (authentication worked). It’s an authorization gap: the principal has no RBAC at the target scope. Grant Contributor (or the least-privilege role) on the resource group.
  5. They are identifiers, not credentials — there is no password to protect. Treating them as secrets adds friction and falsely implies confidentiality; the actual security comes from the federated-credential trust + RBAC, not from hiding these GUIDs.

Glossary

Next steps

AzureGitHub ActionsOIDCFederated CredentialsEntra IDWorkload Identityazure/loginCI/CD
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading