📋 In This Post
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.
| Difficulty | Intermediate — four resources, but the trust relationship spans GitHub and Entra and has to match exactly |
| Time to complete | Under an hour, most of it spent on the subject claim. The proving workflow itself runs in 19 seconds |
| Deployment | Terraform (azurerm), state in HCP Terraform, local execution |
| Measured cost | $0.00 — managed identities and federated credentials are free |
| Full lab guide | LAB.md — prerequisites, variables, commands, expected outputs, validation and cleanup |
Cloud Services and Concepts
| Service | Purpose here | What 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
Prerequisites
- Permissions: Contributor and User
Access Administrator on the subscription. The lab grants
Readerto 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
azurerm5.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
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:
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:
And the trust itself — one credential, GitHub as issuer, the subject carrying numeric IDs:
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.
| Test | Expected | Actual | Status |
|---|---|---|---|
| The managed identity exists | present, client ID resolves | present, client ID resolved | PASS |
| Federated credential count and shape | exactly 1, issuer token.actions.githubusercontent.com, audience api://AzureADTokenExchange | one credential, both correct | PASS |
| Subject names this repo and branch | matches the subject GitHub actually presents | matched | PASS |
| There is no secret to steal | 0 password credentials on the service principal | 0 | PASS |
| Role scope | Reader and nothing else | Reader alone | PASS |
| GitHub Actions can log in with no secret | workflow succeeds, no secrets configured | Success in 19 s, secrets: none configured | PASS |
| The identity cannot write | a resource group create is refused | refused, as designed — the workflow fails the build if it succeeds | PASS |
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:
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: writeis 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/repoform 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'
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
- Workload identity federation
- Authenticate to Azure from GitHub Actions by OpenID Connect
- Create trust between a user-assigned managed identity and an external IdP
- Week 7 code in the lab repository
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