TutorialsDeployment › Identity & Permissions

Deployment · Page 3 of 3

Logging in Without a Password

There is no Azure password, client secret, or publish profile stored anywhere in this repository. GitHub proves which workflow is running with a short-lived signed token, and Azure has been told in advance to trust exactly that description. This page covers how that exchange works, which stored values each environment needs, and the specific Azure roles required — including one that is genuinely easy to get wrong.

Codey the Sr Developer, standing with a pointer, ready to walk through the guide
Secrets stored → IDs only, no passwords Login → OIDC federated credential Environments → Development, Terraform Image pull → managed identity

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.

Codey typing on a laptop
identity, not passwords

The problem with stored credentials why bother

A long-lived secret is a liabilitythree ways
  • 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.
What replaces itOIDC federation

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.

Codey holding a magnifying glass
prove, verify, then act
  1. The job declares permissions: id-token: write, which allows it to request a token describing itself.
  2. 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.
  3. It presents that token to Entra ID along with the AZURE_CLIENT_ID of a user-assigned managed identity.
  4. 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.
  5. Subsequent az commands use that token. What they're permitted to do is decided entirely by the role assignments on that identity.
Two separate identitiesdon't mix them up

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
Why the numbers are in thereimmutable IDs

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.

The environment is part of the identitya useful consequence

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.

Codey holding up a sticky note
two environments, two sets
NameEnvironmentWhat it is
AZURE_CLIENT_IDDevelopmentThe application deployment identity. Changes whenever that identity is recreated.
AZURE_TENANT_IDDevelopmentYour Entra ID tenant. Constant across rebuilds.
AZURE_SUBSCRIPTION_IDDevelopmentThe target subscription. Constant.
TF_AZURE_CLIENT_IDTerraformThe infrastructure identity, created by the bootstrap module.
TF_AZURE_TENANT_ID / TF_AZURE_SUBSCRIPTION_IDTerraformSame tenant and subscription as above, stored separately so the two environments stay independent.
TF_DB_PASSWORDTerraformA 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_LOCATIONTerraform (variables)Non-sensitive settings, stored as variables rather than secrets so they're readable.

Recreating the resource group invalidates AZURE_CLIENT_ID. The managed identity lives inside that group, so tearing it down and rebuilding produces a new one — and the old secret then fails with a confusing error. After any destroy and rebuild, re-read the value from the apply summary and update it.

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.

Codey holding a bug-hunting net
three failures, three roles
RoleGrantsEnough for
AcrPullData-plane pull of image layers.A Web App pulling an image. Not enough to inspect the registry or build in it.
AcrPushData-plane push and pull.docker push to the registry. Still not enough for az acr build.
ReaderControl-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.
Why AcrPush isn't enoughthe non-obvious bit

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.

Who holds what, in this projectthree identities
  • Each Web App's system identityAcrPull on the registry. Just enough to start its own container.
  • The GitHub deployment identityWebsite 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.

What the condition saystwo roles, service principals only

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 honest limitationworth stating plainly

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 seeLikely causeHow to fix it
AADSTS90002: Tenant '***' not foundThe 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 foundThe 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/readThe 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 listBuildSourceUploadUrlThe 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 fulfilledTerraform 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 failsRole 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.”
Codey giving a thumbs up

Passwordless, scoped, and auditable — see the same setup done by hand in az_setup_github_oidc.