Wiring Up GitHub Actions OIDC to Azure: Two Bugs No Guide Mentions

I’ve been running my own SaaS side project - an Intune assessment tool I’ve been building out solo - on entirely manual deployments for a while. func azure functionapp publish from my own machine, a hand-built zip for the Portal, dotnet run against the migration runner when the schema changes. It works, but it’s one person’s muscle memory standing between “push to main” and “actually running.” Today I finally wired up proper CI/CD: GitHub Actions authenticating to Azure via OIDC, no stored secrets, following exactly the pattern every Microsoft doc recommends. Two things went wrong that no doc I’d read mentioned, and both took longer to diagnose than the entire rest of the setup combined.

The setup, which went exactly as documented

Workload identity federation for GitHub Actions is genuinely well-trodden ground: create an Entra app registration, add a federated credential trusting GitHub’s OIDC issuer for your repo, grant it a role, set three repo variables (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID), done. No AZURE_CLIENT_SECRET anywhere, ever - that’s the entire point.

bash
az ad app create --display-name "MyProject-GitHubActions-CI"
APP_ID=$(az ad app list --display-name "MyProject-GitHubActions-CI" --query "[0].appId" -o tsv)
az ad sp create --id "$APP_ID"

az ad app federated-credential create --id "$APP_ID" --parameters '{
  "name": "github-main-branch",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:my-org/my-repo:ref:refs/heads/main",
  "audiences": ["api://AzureADTokenExchange"]
}'

That subject line is copy-pasted from Microsoft’s own documentation, GitHub’s own documentation, and about a dozen blog posts. It’s the standard format everywhere.

Bug one: the subject claim GitHub actually sends isn’t that

I always test a fresh OIDC setup with a throwaway workflow that does nothing but log in and run az account show - cheapest possible way to isolate “is auth even working” from “does the rest of the pipeline work.” First run:

code
##[error]AADSTS700213: No matching federated identity record found for presented assertion subject
'repo:my-org@61777442/my-repo@1304143792:ref:refs/heads/main'.
Check your federated identity credential Subject, Audience and Issuer against the
presented assertion.

Look at that subject string again. It’s not repo:my-org/my-repo:ref:refs/heads/main. It’s repo:my-org@61777442/my-repo@1304143792:ref:refs/heads/main - the owner’s and repository’s numeric IDs, appended to their names with an @. Every piece of documentation I’d read, and the federated credential I’d just created, assumed the plain name-only format.

I don’t know exactly which GitHub configuration produces the ID-suffixed variant versus the plain one - it may be an org-level setting, an Enterprise Managed Users thing, or something else entirely, and I didn’t chase that down because it didn’t need chasing. The fix doesn’t require knowing why:

bash
az ad app federated-credential update --id "$APP_ID" --federated-credential-id "github-main-branch" --parameters '{
  "name": "github-main-branch",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:my-org@61777442/my-repo@1304143792:ref:refs/heads/main",
  "audiences": ["api://AzureADTokenExchange"]
}'

Re-ran the same login workflow. Green.

The actual lesson: don’t try to predict the subject claim format from documentation at all. Create the app registration, put in any federated credential (even one you’re fairly sure is wrong), trigger the workflow, and read the exact subject string back out of the resulting AADSTS700213 error. That error message is more authoritative than any guide, because it’s telling you precisely what your environment produces. Then match the federated credential to that. Thirty seconds, and it’s right regardless of which format your GitHub setup happens to use.

Bug two: az role assignment create lies about what’s wrong

Next step, grant the new identity Contributor on the two resource groups it needs:

bash
az role assignment create --assignee "$APP_ID" --role "Contributor" \
  --scope "/subscriptions/<sub-id>/resourceGroups/rg-my-app"
code
ERROR: (MissingSubscription) The request did not have a subscription or a valid tenant level resource provider.

My subscription context was correct - az account show confirmed it, the resource group unquestionably existed, and I tried the obvious variations: --subscription passed explicitly, --assignee-object-id with --assignee-principal-type ServicePrincipal to skip the display-name lookup entirely. Same error, every time, worded like something structural was wrong with the request rather than a straightforward permissions problem.

I didn’t get to the bottom of what’s actually broken in that specific Azure CLI installation - possibly a stale internal Graph API call, possibly a version-specific regression, I genuinely don’t know. What I do know is the fix, because switching tools sidestepped it entirely:

powershell
$spId = "<service principal object id>"
$subId = "<subscription id>"
New-AzRoleAssignment -ObjectId $spId -RoleDefinitionName "Contributor" `
  -Scope "/subscriptions/$subId/resourceGroups/rg-my-app"

Worked on the first try. Same operation, same identity, same scope, different SDK - the Az PowerShell module doesn’t share whatever code path az role assignment create was hitting.

The lesson: if az role assignment create throws MissingSubscription and your subscription context is provably fine, don’t spend time chasing RBAC or resource-provider-registration theories. Try the Az PowerShell equivalent first. It cost me two minutes once I stopped trusting the error message and just changed tools.

Why the real deploy step still isn’t automatic

Once both of those were sorted, the workflow authenticates cleanly and the read-only what-if job now runs on every PR and push. I deliberately didn’t wire the actual az deployment sub create step to run automatically on push to main, though - there’s no automated test suite in this project yet, so letting every push silently reach production infrastructure felt like trading one risk (manual deploys) for a worse one (untested automatic deploys). The real deploy job is gated behind a manual workflow_dispatch with a typed confirmation input instead. Automating it fully is the obvious next step, once there’s something automated to gate it on.

The rest of the day, briefly

CI/CD wasn’t the only thing that moved today. I also finally committed a genuinely embarrassing backlog of local-only changes - a month’s worth of features that existed only on my machine and nowhere else, which is its own lesson about letting “I’ll commit it properly later” compound. And while redeploying the Portal to pick up a small feature (a tenant offboarding toggle), I hit a live, in-the-wild instance of a zip-corruption bug I’d only previously seen in a different Azure product - PowerShell’s zip tooling on Windows happily writes backslash path separators into archive entries, and Linux App Service’s zip-deploy can’t extract them. Regular readers might remember the Hugo/Azure Static Web Apps post from a couple of weeks ago - different Azure service, same underlying category of problem: a build tool on Windows assumes path conventions that don’t survive contact with Linux. I ended up building the deployment zip with Python’s zipfile module instead, which normalises path separators regardless of host OS, and that’s now just how I package it.

None of today’s bugs were hard once found. All three took longer than they should have because the error messages actively pointed away from the actual cause. That seems to be the pattern worth remembering more than any individual fix: when a well-documented, widely-used pattern fails in a way the documentation doesn’t cover, the fastest path out is usually to stop guessing from docs and go straight to what the system is actually telling you - even when what it’s telling you looks wrong.