GitHub Actions OIDC to Azure: the passwordless setup, end to end
Stop pasting a client secret into repository secrets. With OpenID Connect, a workflow proves who it is at run time and gets a token that dies minutes later. Here is the whole wiring — Entra app, federated credential, role, and the YAML that ties them together.
New to cloud? CAMPUX is a free, build-first course. Start here →
OIDC lets a GitHub Actions workflow authenticate to Azure with a short-lived token instead of a stored secret. You register an Entra app and a federated credential, grant that identity a role on your subscription, and add azure/login with permissions: id-token: write to the workflow — no client secret is ever stored or rotated. That single change removes the most common long-lived credential in a pipeline.
I have cleaned up enough leaked AZURE_CREDENTIALS blobs to have opinions here. A stored client secret is a password that lives in your repo settings for a year, gets copied into three other repos, shows up in a support ticket, and never gets rotated until it expires and breaks a deploy at 2am. Federated identity — OIDC — makes that whole class of problem go away. It is the default Microsoft and GitHub now point you at, and once you have set it up once it is genuinely less work than managing secrets.
What OIDC actually is (the part that trips people up)
The mechanism is a token exchange, and it is worth understanding because every setup error below comes from misreading it. When a workflow runs, GitHub's own OIDC provider mints a short-lived, signed JWT that describes the run: which repository, which branch or tag, which environment, which event triggered it. The azure/login action takes that GitHub token and presents it to Microsoft Entra, asking to be logged in as a specific app registration.
Entra does not trust that token blindly. It checks the token's signature against GitHub's public keys, then compares the token's subject (sub) claim against a federated credential you registered on the app beforehand. If — and only if — the subject matches exactly, Entra issues an Azure access token for that identity. No shared secret changes hands in either direction. The trust is established once, out of band, by you telling Entra "a token from GitHub whose subject is this exact string is allowed to be this app."
That subject string is the whole game, and it is where nearly everyone gets stuck. GitHub builds it from the run context, and the shape differs by trigger:
- A push to a branch:
repo:my-org/my-repo:ref:refs/heads/main - A run tied to a deployment environment:
repo:my-org/my-repo:environment:production - A pull request:
repo:my-org/my-repo:pull_request
A federated credential registered for main will not match a run triggered from a tag, a different branch, or an environment. If the subject in the token does not equal the subject on a credential, character for character, Entra refuses and you get a login failure. That is not a bug — it is the entire security boundary doing its job.
Step 1 — Create the Entra app and service principal
You need an app registration to act as the identity, plus a service principal (the app's instance in your tenant that a role can be assigned to). With the Azure CLI:
# create the app registration and capture its appId (this is your client-id) appId=$(az ad app create --display-name "gha-campux-deploy" --query appId -o tsv) # create the matching service principal in your tenant az ad sp create --id "$appId"
Hold on to three values you will feed the workflow later: the appId above (client ID), your tenant ID, and your subscription ID. Get the last two with az account show --query tenantId -o tsv and az account show --query id -o tsv.
Step 2 — Add the federated credential (this is the trust)
Now you tell Entra which GitHub subject is allowed to log in as this app. Register one credential per subject you need. For pushes to main:
az ad app federated-credential create --id "$appId" --parameters '{ "name": "gha-main-branch", "issuer": "https://token.actions.githubusercontent.com", "subject": "repo:my-org/my-repo:ref:refs/heads/main", "audiences": ["api://AzureADTokenExchange"] }'
The issuer and audiences values are fixed for GitHub Actions — leave them exactly as shown. Only subject changes with what you want to trust. If you deploy through a GitHub environment (recommended for production, because it lets you add required reviewers), register that subject instead:
# environment-scoped: subject uses environment:NAME, not ref:refs/heads/... --parameters '{ "name": "gha-prod-env", "issuer": "https://token.actions.githubusercontent.com", "subject": "repo:my-org/my-repo:environment:production", "audiences": ["api://AzureADTokenExchange"] }'
And for pull-request validation runs, the subject has no ref — it is simply repo:my-org/my-repo:pull_request. Add a separate credential for each; an app can hold several.
The subject is compared literally. repo:My-Org/My-Repo:... and repo:my-org/my-repo:... are different strings to Entra even though GitHub URLs are case-insensitive. Copy the owner and repo exactly as GitHub stores them, and match the ref name precisely — refs/heads/main, not main.
Step 3 — Assign a role (authentication is not authorization)
The federated credential only lets the identity log in. It still cannot touch a single resource until you give it an RBAC role. Scope it as narrowly as the job allows — a resource group is better than a whole subscription:
# grant Contributor on one resource group (prefer this over subscription scope) az role assignment create \ --assignee "$appId" \ --role "Contributor" \ --scope "/subscriptions/<sub-id>/resourceGroups/rg-campux-prod"
Skip this step and your login will succeed but the very next Azure command fails with "No subscriptions found" or an authorization error — the classic "it authenticated, why can't it do anything" trap.
Step 4 — The workflow YAML
Two things make this work: the id-token: write permission, which is what lets the runner request a GitHub OIDC token in the first place, and azure/login@v2 with the three IDs and no client secret.
name: deploy
on:
push:
branches: [main]
permissions:
id-token: write # required — lets the job fetch an OIDC token
contents: read # restore checkout, since setting permissions resets the rest
jobs:
deploy:
runs-on: ubuntu-latest
# environment: production # uncomment if you registered the environment subject
steps:
- uses: actions/checkout@v4
- name: Azure login (OIDC, no secret)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Prove it works
run: az account show
Note what is not there: no client-secret, no creds: JSON blob. The three values you store as repository secrets — client ID, tenant ID, subscription ID — are not secrets in any real sense; they are identifiers. Anyone holding them still cannot log in, because logging in requires a token whose subject Entra trusts, and only your workflow, running in your repo on the right branch, can produce that. That is the whole point.
Nothing long-lived is stored. The credential exists only for the minutes your job runs, and only for the exact repo and branch you named.
Stored secret vs OIDC, side by side
| Concern | Stored client secret | OIDC federated credential |
|---|---|---|
| Lifetime | Months to years; sits in repo secrets until it expires | Minted per run, expires in minutes |
| Rotation | Manual; forgotten until a deploy breaks | None — there is nothing to rotate |
| Leak risk | Can leak via logs, forks, a bad action, or copy-paste | No standing secret to leak |
| Scope | Anyone with the secret can log in from anywhere | Bound by subject to one repo + branch/environment |
| Setup | Create secret, paste into repo, repeat per repo | Register a federated credential once per subject |
When it does not work — the four failures you will hit
"Unable to get ACTIONS_ID_TOKEN_REQUEST_URL"
The job was never allowed to request a token. You are missing permissions: id-token: write. Add it at the workflow or job level. Remember that adding any permissions block sets every other scope to none, so include contents: read if a step checks out code.
Login fails with a subject-claim mismatch (often AADSTS70021)
Entra found no federated credential matching the token's subject. Print the run's context to see what subject GitHub actually sent, then confirm a credential exists for it. The usual causes: the run came from a branch or tag you did not register, you registered ref:refs/heads/main but deploy from an environment (or vice versa), or the org/repo casing differs.
"No subscriptions found for the given account"
Authentication worked; authorization did not. The service principal has no role on any subscription. Go back to Step 3 and create a role assignment scoped to your subscription or resource group, and make sure you passed subscription-id to azure/login.
It works on push but fails on pull requests
PR runs carry a different subject (...:pull_request) than branch pushes. Register a separate federated credential for it — and think hard before granting write roles to PR-triggered runs from forks.
Questions people also ask
What is OIDC authentication in a CI/CD pipeline?
OIDC lets a pipeline prove its identity to a cloud with a short-lived token issued at run time instead of a stored secret. GitHub's OIDC provider mints a signed JWT describing the workflow — its repo, branch, and environment. The workflow trades that token for a cloud access token, and the cloud grants it only if the token's subject claim matches a federated credential you registered in advance. Nothing long-lived is stored in the repo.
How do I fix "No subscriptions found for the given account" after azure/login?
The login itself succeeded but the service principal has no role on any subscription. Assign it a role scoped to the subscription or resource group you deploy to — for example a Contributor role assignment with az role assignment create — and pass subscription-id to azure/login. The federated trust only handles authentication; you still need an RBAC assignment for authorization.
Why does OIDC login fail with a subject claim mismatch?
Azure rejects the token because its subject claim does not exactly match any federated credential on the app. GitHub's subject is built from context — repo:owner/name:ref:refs/heads/main for a branch, repo:owner/name:environment:production for an environment, repo:owner/name:pull_request for a PR. A credential registered for the main branch will not match a run from a tag, a different branch, or an environment. Register a credential whose subject matches the exact trigger, and check for typos in the owner and repo name.
Is OIDC more secure than a stored client secret?
Yes. A stored client secret is a long-lived credential sitting in repository or organization secrets — it can leak in logs, forks, or a compromised action, and someone has to remember to rotate it. An OIDC token is minted per run, expires in minutes, never leaves the runner, and is scoped by the subject claim to a specific repo and branch or environment. There is no secret to leak and nothing to rotate.