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

Lab Overview

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.

DifficultyIntermediate — four resources, but the trust relationship spans GitHub and Entra and has to match exactly
Time to completeUnder an hour, most of it spent on the subject claim. The proving workflow itself runs in 19 seconds
DeploymentTerraform (azurerm), state in HCP Terraform, local execution
Measured cost$0.00 — managed identities and federated credentials are free
Full lab guideLAB.md — prerequisites, variables, commands, expected outputs, validation and cleanup

Cloud Services and Concepts

ServicePurpose hereWhat it is
Federated identity credential Trust established without a secret A credential that matches an external token by issuer, audience and an exact subject, instead of holding a password to compare.
OIDC token from GitHub Actions What the workflow presents A short-lived token GitHub mints for that run. Its subject encodes the repository and the ref by immutable numeric ID.
Microsoft Entra app registration What the token is exchanged for The Azure identity the workflow becomes. It holds no secret of any kind in this design.
Repository variables What is stored on the GitHub side Client, tenant and subscription IDs. Identifiers, not credentials — secrets configured: 0.
Role assignment What the returned token can do Reader on one subscription. Reads succeed and creates are refused, which is what makes the test meaningful rather than decorative.

Architecture and Workflow

Architecture diagram. A GitHub Actions workflow holds three repository variables and zero secrets. It presents an OIDC token to a federated credential configured once in Microsoft Entra, which matches on issuer, audience and an exact subject built from immutable numeric repository IDs. What comes back is an Azure access token valid for that run only, carrying exactly one role assignment: Reader on the lab subscription. Reads succeed and creates are refused.
The whole week on one page. Follow the token: GitHub mints it, Entra checks issuer, audience and an exact subject against the federated credential, and returns an Azure token. Nothing stored, nothing to rotate, nothing to leak.

Prerequisites

  • Permissions: Contributor and User Access Administrator on the subscription. The lab grants Reader to the identity it creates, and Contributor cannot create role assignments.
  • A GitHub repository you can run Actions in, and permission to add a workflow to it. The federated credential is pinned to one org, repo and branch, so you need to know all three before deploying.
  • The GitHub CLI (gh), authenticated. Not optional: the subject claim must be read from GitHub, not constructed by hand. See Troubleshooting for what happens otherwise.
  • Terraform ≥ 1.10 with azurerm 5.x. The workspace must be set to local execution — a new HCP workspace defaults to remote, and there is no Azure CLI on HCP's runners.
  • One resource provider: Microsoft.ManagedIdentity, registered by the deploy script.
  • No prior week is required. This one is depended on rather than depending: it is the identity later weeks would build in CI under.

Every variable — including the three GitHub ones with no defaults — is in the lab guide, which is the reproducible half of this write-up.

Implementation

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

The Terraform file tree for this week: terraform/ with versions.tf, variables.tf, a 75-line main.tf holding all four resources, outputs.tf and a tfvars example; scripts/ with deploy, validate and cleanup; and the GitHub Actions workflow, each annotated with its job and line count
Every file, and what it is for. No modules and no environments/ directory: four resources, deployed once, to one subscription. A module wrapping four resources used in one place is indirection, not abstraction. terraform.tfvars is gitignored — it carries the tenant and subscription IDs.

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.

State lives in HCP Terraform, and the workspace runs in local execution — HCP holds the state file, the apply runs here:

HCP Terraform workspace azure-week-07-dev in the Azure Platform Lab project, showing execution mode Local and the workspace resources
Checkpoint: Execution mode: Local. A new HCP workspace defaults to remote, and a remote plan here fails with az: executable file not found — which reads exactly like a broken laptop and is not.

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
The identity CI authenticates as. User-assigned, so it outlives any one deployment. Client and object IDs are redacted here, but the client ID is not a secret — the workflow passes it as a plain variable.

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
What the trust is actually pinned to: one credential, GitHub as issuer, and a subject beginning repo:katta698@63027619. That numeric suffix is the immutable owner ID, and it is why a hand-built subject does not match.

Validation and Evidence

A successful terraform apply proves four resources exist. It proves nothing about whether GitHub can actually authenticate with them, which is the entire claim of the week. The checks below are split accordingly: the first five run against Azure, and the last two run on a real GitHub runner.

TestExpectedActualStatus
The managed identity existspresent, client ID resolvespresent, client ID resolvedPASS
Federated credential count and shapeexactly 1, issuer token.actions.githubusercontent.com, audience api://AzureADTokenExchangeone credential, both correctPASS
Subject names this repo and branchmatches the subject GitHub actually presentsmatchedPASS
There is no secret to steal0 password credentials on the service principal0PASS
Role scopeReader and nothing elseReader alonePASS
GitHub Actions can log in with no secretworkflow succeeds, no secrets configuredSuccess in 19 s, secrets: none configuredPASS
The identity cannot writea resource group create is refusedrefused, as designed — the workflow fails the build if it succeedsPASS

The fourth row is the one that cannot be achieved by discipline. A managed identity cannot hold a password — the API has nowhere to put one. In an app registration, "no secret" is a rule somebody has to keep keeping.

What the checks print

What two of those checks print:

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
The evidence that matters. A real runner, a real login, Success in 19 seconds — and note the run number: this is #2, the one that read the subject from GitHub. The repository's secrets are not visible here; the workflow prints them itself below. A passing validate.sh only ever proved Azure's half.

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.

Security and Cost

Security

  • 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.

Troubleshooting and Lessons

The first run failed at login, not at apply.

  • Problem: Terraform applied cleanly and the workflow then failed authenticating, with AADSTS700213: No matching federated identity record found.
  • Root cause: GitHub now defaults to immutable subject claims — the owner and repository each carry their numeric ID. A credential built from the documented repo:owner/repo form applies cleanly and matches nothing a runner actually presents.
  • Resolution: read the subject from GitHub instead of constructing it.
  • Lesson learned: a trust relationship has two ends, and only one of them is in your Terraform. The half you did not write is the half to go and read.
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
The fix, as history. The first run failed on the subject claim; the next one, titled for reading the subject from GitHub rather than building it, succeeded. Two runs is the whole distance between assuming and asking.

That immutability is deliberate: renaming or transferring a repository no longer carries the Azure trust with it. The fix is to ask GitHub rather than assume:

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.

Cleanup and Key Takeaways

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