Deploy to Azure with no stored secrets: OIDC pipelines, end to end
For years, connecting a pipeline to Azure meant pasting a client secret into your CI settings and praying it never leaked. Workload identity federation ends that: your workflow trades a short-lived token for an Azure token at run time, with nothing to store, rotate, or steal. Here is the idea, then the whole wiring.
New to cloud? CAMPUX is a free, build-first course. Start here →
Think about the old way for a second, because its badness is the whole motivation. To let a pipeline deploy to Azure you created a service principal with a client secret, then pasted that secret into your repository's CI settings. That secret was a long-lived password to your cloud, sitting in a system outside Azure, that you had to remember to rotate and hope nobody exfiltrated. It was the single most valuable thing in your pipeline and the easiest thing to leak.
I have cleaned up enough leaked credential blobs to have opinions here. A stored client secret gets copied into three other repositories, shows up in a support ticket, and never gets rotated until it expires and breaks a deploy at 2am. Federated identity makes that whole class of problem go away, and once you have set it up once it is genuinely less work than managing secrets.
The idea: trust, not secrets
Microsoft's definition is the thesis: workload identity federation "enables secure access to Microsoft Entra protected resources without managing secrets." Instead of storing a credential, you configure a trust relationship. You tell an app registration or a user-assigned managed identity in Entra ID to trust tokens from an external identity provider, and for a pipeline that provider is your CI system. Once that trust exists, the workflow exchanges a token it already receives for a real Azure access token, on demand.
The thing that authorizes your deploy is no longer a secret you hold. It is a relationship you declared: "I trust tokens that come from this specific repository, on this specific branch." There is nothing to paste anywhere.
How the exchange works
The flow is short, and it happens fresh on every run. The workflow asks its CI provider for a token. The provider issues a short-lived, signed token describing the run — which repository, which branch, which environment, which event triggered it. The sign-in step sends that token to the Microsoft identity platform and asks for an access token. Entra checks the signature against the provider's public keys, then compares the token's subject claim against a federated credential you registered beforehand. If, and only if, the subject matches exactly, Entra issues an Azure access token. The workflow uses it to deploy.
No shared secret changes hands in either direction. The trust was established once, out of band, by you telling Entra that a token from this provider 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. It is built from the run context, and its 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 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. 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, which is the app's instance in your tenant that a role can be assigned to.
# create the app registration and capture its appId (this is your client-id) appId=$(az ad app create --display-name "gha-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 (the 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 tell Entra which subject is allowed to log in as this app. Register one credential per subject you need.
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 — leave them exactly as shown. Only subject changes with what you want to trust. If you deploy through a deployment environment, which is recommended for production because it lets you add required reviewers, register that subject instead: repo:my-org/my-repo:environment:production. For pull-request validation runs the subject has no ref at all, just repo:my-org/my-repo:pull_request. Add a separate credential for each; one 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 the URLs are case-insensitive. Copy the owner and repository exactly as they are stored, 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 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-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
Two things make this work: the id-token: write permission, which is what lets the runner request a token in the first place, and the login action 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
steps:
- uses: actions/checkout@v7
- 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 credentials blob. The three values you store as repository secrets 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 repository on the right branch, can produce that.
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 repository 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 repository and branch or environment |
| Setup | Create secret, paste into repo, repeat per repo | Register a federated credential once per subject |
When it does not work — the 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, and 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
Entra found no federated credential matching the token's subject. Print the run's context to see what subject was 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 a branch ref but deploy from an environment (or the reverse), or the organization and repository 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, create a role assignment scoped to your subscription or resource group, and make sure you passed the subscription ID to the login step.
Why this is the standard now
Two reasons, and neither is fashion. The first is that the credential no longer exists to be stolen: there is no string sitting in a settings page that grants access to your cloud, so the most common pipeline compromise simply has no target. The second is that the trust is scoped in a way a secret never was. A secret works from anywhere, for anyone holding it. A federated credential works only for a token whose subject matches the repository and branch you named, which means a fork, a different branch, or another team's pipeline cannot use it even if they somehow obtained the identifiers.
If you still have a stored secret in a pipeline, the migration is safe to stage: register the federated credential alongside the existing secret, switch the workflow, confirm a real run authenticates, and only then delete the secret. The old path keeps working until you remove it, so there is no cutover gap. The wider decision of which credential belongs on which workload is in managed identity vs service principal.
Questions people also ask
What is OIDC in GitHub Actions?
OpenID Connect lets a workflow prove its identity to a cloud provider at run time instead of holding a stored credential. The CI provider mints a short-lived signed token describing the run — repository, branch, environment, trigger — and the cloud exchanges that token for its own access token if the run matches a trust relationship configured in advance.
How does a pipeline authenticate to Azure without secrets?
Through workload identity federation. You register an app in Microsoft Entra and add a federated credential naming the issuer and the exact subject you trust, then grant that identity an RBAC role. At run time the workflow presents its short-lived token, Entra validates the signature and matches the subject against the credential, and issues an Azure access token. No client secret is stored or rotated.
What is workload identity federation?
It is the Entra feature that lets an external identity provider's tokens stand in for a stored credential. You declare a trust relationship once — this issuer, this subject, this audience — and thereafter the external workload authenticates with tokens it already receives, rather than with a password you have to protect.
Why use OIDC instead of a service principal secret?
Because the secret is the risk. A stored client secret is long-lived, has to be rotated manually, can leak through logs or forks, and works from anywhere for anyone holding it. A federated credential is minted per run, expires in minutes, needs no rotation, and only works for a token whose subject matches the repository and branch you registered.
What permission does the workflow need to request an OIDC token?
It needs id-token: write, set at the workflow or job level. Without it the runner cannot fetch a token and the login step fails before it reaches Azure. Adding a permissions block resets every other scope to none, so add contents: read as well if any step checks out the repository.
Why does the login fail with a subject-claim mismatch?
Because no federated credential matches the subject in the token. The subject is compared literally, so a credential registered for a branch ref will not match a run from an environment, a tag, or a pull request, and the organization and repository casing must match exactly. Register one credential per subject you deploy from.