Home› Blog› Week 7 — CI That Deploys to Azure With No Password…
Azure Weekly Lab Azure Terraform

Week 7 — CI That Deploys to Azure With No Password Anywhere

GitHub Actions authenticates to Azure, reads the subscription, and is refused when it tries to create anything. There is no secret in the repository — not a hidden one, not a rotated one. None exists.

Workload Identity Managed Identity OIDC GitHub Actions Terraform
Azure Platform Engineering Lab · Week 7 of 52

Why — The Problem This Solves

A CI pipeline that deploys to Azure without storing a credential. GitHub mints a short-lived token describing the run, Azure checks it against a trust configured once, and hands back an access token.

The point is not that the secret is well hidden. It is that no password is ever created, so there is nothing to leak, rotate, or find in a repo three years later.

What You Need to Know — Skills & Tools

  • Workload identity federation — a trust between an external token issuer and an Azure identity, replacing a stored credential.
  • User-assigned managed identity, and that system-assigned cannot do federation.
  • The OIDC subject claim — what a runner asserts about itself, matched exactly.
  • GitHub repository variables versus secrets, and why these three are variables.

Architecture — How It Fits Together

Diagram: a GitHub Actions run mints an OIDC token describing the repository and branch. Entra checks it against a federated credential on a user-assigned managed identity, matching issuer, audience and an exact subject, then returns an Azure access token carrying only the Reader role.

How We Built It — In Deployment Order

1. The identity, and the trust that makes it usable

Four resources. The identity has to be user-assigned — federation does not work with a system-assigned identity, because there is no Azure resource here to own one. The workload runs on GitHub's machines.

resource "azurerm_user_assigned_identity" "ci" {
  name                = "id-wk07-github-ci-dev-scus-001"
  resource_group_name = azurerm_resource_group.identity.name
  location            = azurerm_resource_group.identity.location
}

resource "azurerm_federated_identity_credential" "main_branch" {
  name                      = "github-main"
  user_assigned_identity_id = azurerm_user_assigned_identity.ci.id

  audience = ["api://AzureADTokenExchange"]
  issuer   = "https://token.actions.githubusercontent.com"
  subject  = "${var.github_subject_prefix}:ref:refs/heads/main"
}

resource "azurerm_role_assignment" "ci_reader" {
  scope                = "/subscriptions/${var.subscription_id}"
  role_definition_name = "Reader"          # not Contributor
  principal_id         = azurerm_user_assigned_identity.ci.principal_id
}

subject is the security boundary, and it is exact-match, not a prefix. A different repo, or the same repo on a different branch, presents a different subject and is refused — which is why a fork raising a pull request cannot borrow this identity.

Reader, not Contributor. This workload reads. Granting more "so it works later" is how a CI identity quietly becomes the most powerful principal in a tenant.

2. Deploying it

Terraform, state in HCP Terraform, apply running locally:

./scripts/deploy.sh      # reads the subject prefix from GitHub, then applies
./scripts/validate.sh    # 5 checks against the live identity
./scripts/cleanup.sh     # destroys it and verifies against Azure

The deploy prints three identifiers, and they go into GitHub as repository variables — Settings → Secrets and variables → Actions → Variables:

AZURE_CLIENT_ID   AZURE_TENANT_ID   AZURE_SUBSCRIPTION_ID

Variables, not secrets. They are identifiers; knowing them grants nothing without a token GitHub will only mint for this repo on this branch. Three variables and zero secrets is the whole week on one settings page.

Wait — who created the identity, if GitHub needs it to exist first? Not GitHub. deploy.sh runs on my machine against my own az login session, and creates the identity, the credential and the role assignment. Only then does the workflow have anything to log into. There is no circularity, because creating the trust and using it are two different jobs done by two different principals.

There is a real bootstrap problem underneath, though, and it is worth saying plainly: the first credential always comes from somewhere else. Federation removes the stored secret from CI. It does not remove the need for a privileged human, once, to establish the trust. What you gain is that the thing running a thousand times a week holds nothing, while the thing that ran once was a person at a keyboard.

3. What appeared in the portal

The identity, with no credential of any kind attached to it:

Azure portal overview for the user-assigned managed identity id-wk07-github-ci-dev-scus-001, showing its resource group, location and type, with client ID and object ID redacted

And the trust itself — one credential, GitHub as issuer, the subject carrying numeric IDs:

Azure portal federated credentials blade showing one credential named github-main, issuer token.actions.githubusercontent.com, and a subject identifier beginning repo:katta698@63027619

How It's Tested

validate.sh runs five checks. The one that matters is the fourth:

4. There is no secret to steal
   password credentials on the identity's service principal: 0
   RESULT: none - authentication is a federated token, minted per run

5. The role is Reader, and only Reader
   roles: Reader
   RESULT: Reader alone - it can look, and cannot change anything

── 5 passed, 0 failed ──

A managed identity cannot hold a password — the API has nowhere to put one. That is the difference from an app registration, where "no secret" is a discipline somebody has to keep.

Then the workflow, on a real runner. GitHub Actions is what drives this, so the run page is the evidence that matters:

GitHub Actions run summary for the workflow named Azure login with no secret, showing status Success, a single job called prove-it, and a total duration of 19 seconds

And its output:

client id  : f8bfdca3-...          <- a variable, not a secret
secrets    : none configured for this repo

── who am I ──
Subscription    Tenant
sub-lab-dev     29a908ac-...

── what can I see ──
rg-wk07-identity-dev-scus-001

── what I cannot do (Reader, deliberately) ──
refused, as designed - this identity reads and nothing more

The workflow asserts its own limit rather than assuming it: it attempts a resource group create and fails the build if that succeeds.

Challenges — What Actually Went Wrong

The first run failed at login, not at apply.

AADSTS700213: No matching federated identity record found for presented
assertion subject 'repo:katta698@63027619/Azure-Platform-Engineering-Lab@1342954118:ref:refs/heads/main'

GitHub Actions run history for the workflow, showing the first run failed and the following run, titled Read the OIDC subject from GitHub instead of building it, succeeded

GitHub now defaults to immutable subject claims: the owner and the repository each carry their numeric ID. That is deliberate — renaming or transferring a repo no longer carries the Azure trust with it. It also means the documented repo:owner/repo form is not what a runner presents, so a credential built from the names applies cleanly and matches nothing. The fix is to ask GitHub instead of assuming:

gh api repos/{owner}/{repo}/actions/oidc/customization/sub --jq '.sub_claim_prefix'

A new HCP workspace defaults to remote execution. The plan ran on HCP's servers and failed with az: executable file not found in $PATH — which reads exactly like a local problem. There is no Azure CLI on HCP's runners and no credentials there either. Setting the workspace to local execution fixed it immediately.

azurerm 5.x changed the resource. azurerm_federated_identity_credential takes a single user_assigned_identity_id where 4.x took resource_group_name plus parent_id, so every example predating 5.0 fails on all three arguments at once.

Security — Controls at Every Layer

  • No secret exists, rather than no secret being committed.
  • Tokens are minted per run and expire with it — nothing to rotate.
  • Trust is pinned to one repo and one branch, with immutable IDs so a rename cannot carry it.
  • Reader only, asserted by the workflow rather than assumed.
  • permissions: id-token: write is required, or GitHub never mints a token and the error points at Azure instead of at the workflow.

Cost

Managed identities, federated credentials and role assignments are all free. This week cost $0.00.

Cleanup

cleanup.sh destroys all four resources and then checks Azure directly — 0 resource groups, 0 identities, 0 orphaned role assignments. Not trusted from an exit code.

One bug found doing it: deploy.sh had learned to pass the subject prefix and cleanup.sh had not, so the teardown failed with No value for required variable. Terraform wants every declared variable at destroy too — the worst place to discover a missing argument.

References

Key Takeaways

  • "No secret" should be a property, not a discipline. A managed identity cannot hold a password. That is a stronger guarantee than remembering not to create one.
  • Federation needs a user-assigned identity. System-assigned cannot do it, and the reason is structural rather than arbitrary.
  • Ask GitHub for the subject. Immutable subject claims mean the documented format is no longer what runners send, and the mismatch only shows at login — long after a clean apply.
  • Grant Reader and make the pipeline prove it. A test that asserts what CI cannot do is worth more than one that asserts what it can.

Comments

How was your experience?
Your feedback helps improve this site.
PoorExcellent