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
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:

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

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:

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