Purpose of this page
Understand how a workflow authenticates to Azure with no stored secret, what the three identities in this project are allowed to do, and how to read an authorization failure.
The problem with stored credentials why bother
- It leaks. Anything pasted into a settings box can be pasted somewhere else — a log, a screenshot, a support ticket.
- It expires, noisily. Client secrets have expiry dates, and the first sign is usually a broken deployment at an inconvenient moment.
- It doesn't know who's using it. A secret authenticates whoever holds it, whether that's your workflow, a fork, or someone who copied it.
GitHub already knows, with certainty, which repository, branch, and environment a run belongs to. OIDC lets it issue a signed token stating those facts, valid for minutes. Azure verifies the signature, checks the statement against a rule you configured, and hands back a short-lived access token. Nothing durable is ever stored on the GitHub side — only non-secret identifiers.
How the exchange works step by step
Four parties: the workflow, GitHub's token service, Microsoft Entra ID, and the Azure resource being acted on. What follows happens inside the azure/login step, in about a second.
- The job declares permissions: id-token: write, which allows it to request a token describing itself.
- azure/login asks GitHub for that token. GitHub signs a statement including an issuer (token.actions.githubusercontent.com), an audience (api://AzureADTokenExchange), and a subject describing the exact repository and environment.
- It presents that token to Entra ID along with the AZURE_CLIENT_ID of a user-assigned managed identity.
- Entra ID looks up the federated identity credential attached to that identity and checks whether the issuer, audience, and subject all match. If they do, it returns a normal, short-lived Azure access token.
- Subsequent az commands use that token. What they're permitted to do is decided entirely by the role assignments on that identity.
This project creates two GitHub-facing identities with different powers: one for deploying applications, trusted for the Development environment, and one for running Terraform, trusted for the Terraform environment. The infrastructure identity is far more privileged, which is exactly why it isn't the one used by routine deploys.
There's a third identity type in play too — each Web App's own system-assigned identity, which it uses to pull images from the registry. No GitHub involvement at all.
The subject claim must match exactly the fiddly part
The subject is the string Entra ID compares character by character. For an environment-scoped run it looks like this:
repo:gdzierzon@9723466/cohort-d-dzierzong-metals-simple-deploy@1361521533:environment:Development
Those numeric suffixes are GitHub's internal owner and repository IDs. They don't change when an account or repository is renamed, so the trust relationship survives a rename — and can't be hijacked by someone later claiming an abandoned name.
Because :environment:Development is inside the subject, a job that doesn't declare environment: Development produces a different subject and is refused. Deleting the environment: key from a workflow doesn't quietly lower security — it stops the login working at all.
Terraform builds this string in main.tf, and the manual OIDC script constructs the same value, with an override parameter for repositories whose subject differs.
What each environment stores and it isn't secret
With OIDC, the stored values are just identifiers — they say which identity to attempt, not how to prove you're allowed to use it. They're still kept as secrets out of habit and to keep run logs tidy, but leaking one grants nobody anything.
| Name | Environment | What it is |
|---|---|---|
AZURE_CLIENT_ID | Development | The application deployment identity. Changes whenever that identity is recreated. |
AZURE_TENANT_ID | Development | Your Entra ID tenant. Constant across rebuilds. |
AZURE_SUBSCRIPTION_ID | Development | The target subscription. Constant. |
TF_AZURE_CLIENT_ID | Terraform | The infrastructure identity, created by the bootstrap module. |
TF_AZURE_TENANT_ID / TF_AZURE_SUBSCRIPTION_ID | Terraform | Same tenant and subscription as above, stored separately so the two environments stay independent. |
TF_DB_PASSWORD | Terraform | A genuine secret — the PostgreSQL administrator password Terraform sets and the API is configured with. |
TF_CLIENT_IP, TF_STATE_STORAGE_ACCOUNT, TF_USER_NAME, TF_LOCATION | Terraform (variables) | Non-sensitive settings, stored as variables rather than secrets so they're readable. |
The roles, and which action needs which learned the hard way
Authentication answers “who are you.” Authorization answers “may you do this.” Azure's container registry roles split along a line that isn't obvious, and getting it wrong produces three different failures in sequence.
| Role | Grants | Enough for |
|---|---|---|
AcrPull | Data-plane pull of image layers. | A Web App pulling an image. Not enough to inspect the registry or build in it. |
AcrPush | Data-plane push and pull. | docker push to the registry. Still not enough for az acr build. |
Reader | Control-plane registries/read. | Looking the registry resource up with az acr show. |
Contributor (scoped to the registry) | Everything above, plus listBuildSourceUploadUrl and scheduleRun. | az acr build — which uploads source and schedules a task run. |
az acr build doesn't build locally and push. It uploads your source to Azure and asks the registry's build service to run the Dockerfile — which are management operations, not image push operations. So the data-plane roles that sound right (AcrPush) don't cover it, and the failure is a 403 naming the exact missing action: Microsoft.ContainerRegistry/registries/listBuildSourceUploadUrl/action.
Read the action name in an AuthorizationFailed message — it tells you precisely what's missing, which is far faster than guessing at roles.
- Each Web App's system identity — AcrPull on the registry. Just enough to start its own container.
- The GitHub deployment identity — Website Contributor on both Web Apps (to configure and restart them) and Contributor on the registry (to build and push).
- The Terraform identity — subscription Contributor, plus a tightly conditioned RBAC administrator role, plus write access to the state container.
Every one of those is scoped as narrowly as the task allows. The registry Contributor grant, for instance, is scoped to the single registry resource — not the resource group, and not the subscription.
The guardrail on granting roles bootstrap
Terraform has to create role assignments, which means it needs permission to grant roles — a power that, unrestricted, would let it grant itself anything. The bootstrap module constrains it with an ABAC condition: an attribute-based rule attached to the role assignment itself.
The Terraform identity holds Role Based Access Control Administrator, but the attached condition permits creating or deleting a role assignment only when the role being granted is Website Contributor or Contributor, and only when the recipient is a service principal. Attempting anything else — granting Owner, for example — is refused by Azure itself, not by convention.
The condition restricts which role may be granted, not at what scope. Because the administrator role is held at subscription level, the Terraform identity could grant Contributor to a service principal anywhere in the subscription — not only on the container registry. That's an accepted trade-off for a teaching subscription; a production setup would narrow the scope as well.
Changing this condition requires applying the bootstrap module locally with an elevated account. The CI pipeline deliberately cannot modify its own guardrail — which is the entire point of keeping bootstrap in a separate state.
Common errors and how to fix them
| What you see | Likely cause | How to fix it |
|---|---|---|
| AADSTS90002: Tenant '***' not found | The tenant ID value is malformed or empty — often a stray newline from pasting. | Re-enter AZURE_TENANT_ID on the environment. Note this fails before any trust check, so it's a value problem, not a permissions one. |
| No matching federated identity record found | The subject GitHub sent doesn't match the configured credential — wrong environment name, wrong branch, or a renamed repository. | Compare the subject printed in the run log against the federated credential, character for character. |
| AuthorizationFailed naming registries/read | The identity can log in but can't see the registry. | It needs at least Reader — in this project, Contributor on the registry covers it. |
| AuthorizationFailed naming listBuildSourceUploadUrl | The classic AcrPush-isn't-enough case. | Grant Contributor scoped to the registry, as github_oidc.tf does. |
| has an authorization with ABAC condition that is not fulfilled | Terraform tried to grant a role outside the bootstrap condition's allow-list. | Widen the condition in bootstrap/main.tf and apply that module locally — the CI identity can't do it for you. |
| A role was granted but access still fails | Role assignments take a short time to propagate. | Wait a couple of minutes and re-run. The error message even hints at it: “If access was recently granted, please refresh your credentials.” |