We ripped every client secret out of our CI pipelines by pointing Azure federated credentials at GitHub's OIDC issuer. Here's the exact setup and the claims that trip people up.
Every Azure service principal secret we ever created eventually became a liability. It sat in a GitHub Actions secret, a Key Vault, someone's .env, and a Terraform state file all at once. The default lifetime is two years, so nobody rotated it, and when it finally expired at 3am on a Sunday the deploy pipeline died with AADSTS7000215: Invalid client secret provided. That is the whole problem in one error code: a long-lived string that grants full access, copied to places you stopped tracking.
Federated identity credentials remove the string. Instead of your workload proving who it is with a secret only it should know, it presents a short-lived OIDC token that some trusted issuer already minted for it. Entra ID checks the token's claims against a rule you configured and, if they match, hands back an Azure access token. Nothing to store, nothing to rotate, nothing to leak. This is the Azure flavor of workload identity federation, and the mechanics line up closely with the AWS OIDC equivalent for GitHub Actions if you've done that already.
You attach a federated credential to either an app registration or a user-assigned managed identity. The credential is not a secret. It is a small object describing which external tokens you trust:
issuer — the OIDC issuer URL that signs the incoming token, e.g. https://token.actions.githubusercontent.com for GitHub Actions.subject — the exact sub claim you expect, e.g. repo:acme/payments-api:ref:refs/heads/main.audiences — what the token was minted for. For Entra this is almost always api://AzureADTokenExchange.When a workload calls Entra's token endpoint with a signed OIDC assertion, Entra fetches the issuer's public keys from its .well-known metadata, validates the signature, then compares iss, sub, and aud against your federated credential. All three match, you get a token. One character off, you get AADSTS70021: No matching federated identity record found.
Say you have an app registration for your CI. Grab its object ID and add a federated credential that trusts pushes to main:
az ad app federated-credential create \
--id "$APP_OBJECT_ID" \
--parameters '{
"name": "gh-payments-main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:acme/payments-api:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'
For a user-assigned managed identity the command is slightly different, but the claim fields are identical:
az identity federated-credential create \
--name gh-payments-main \
--identity-name id-payments-ci \
--resource-group rg-ci \
--issuer "https://token.actions.githubusercontent.com" \
--subject "repo:acme/payments-api:ref:refs/heads/main" \
--audience "api://AzureADTokenExchange"
Then give the underlying principal whatever RBAC it needs, scoped as tightly as you can stand:
az role assignment create \
--assignee "$APP_CLIENT_ID" \
--role "Contributor" \
--scope "/subscriptions/$SUB/resourceGroups/rg-payments"
That is the entire Azure side. No az ad app credential reset, no secret to paste anywhere.
The workflow needs id-token: write so the runner can request an OIDC token, and it passes the three IDs to azure/login. There is no client-secret.
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- run: az group show -n rg-payments -o table
The three values in secrets are just identifiers, not credentials. Leaking them buys an attacker nothing without a token from the trusted issuer bearing the exact subject you allowed.
The GitHub OIDC token lives about five to ten minutes. The Azure access token you exchange it for defaults to roughly an hour and can't be refreshed with the original assertion once that assertion expires. This is the point: the credential exists only for the length of one job. There is no standing secret an attacker can find next quarter. The tradeoff is that long-running jobs past the token lifetime need to re-authenticate, which for most deploy pipelines never comes up.
The subject is an exact string match, not a prefix or a glob. repo:acme/payments-api:ref:refs/heads/main will reject a push to release, a tag, and every pull request. Environment-scoped runs use repo:acme/payments-api:environment:production instead of the ref: form, so you add a separate federated credential per pattern. You can attach up to 20 per identity.
The audience must be api://AzureADTokenExchange. GitHub's default OIDC audience is your tenant URL, so the azure/login action overrides it for you; if you mint the token yourself, set the audience explicitly or Entra rejects it.
Kubernetes workloads follow the same path — the cluster's service account issuer becomes the issuer, and the subject looks like system:serviceaccount:namespace:sa-name. Cross-cloud is identical too: an AWS or GCP workload presenting its own OIDC token works as long as you register that issuer.
Federated credentials are the default for any Azure workload that runs somewhere with an OIDC issuer, which is CI, Kubernetes, and most managed compute now. Keep client secrets only for the rare legacy case that genuinely can't present a token, and put a 90-day expiry and an alert on those. Start with your noisiest pipeline, delete its secret the same afternoon, and watch the expiry pages stop.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Edge runtimes look like Node but aren't. Here's what actually breaks — CPU caps, no filesystem, no TCP sockets — and how we route around it.
How pods can talk to AWS, GCP, and Azure with no static keys — using audience-bound projected ServiceAccount tokens and the cluster OIDC issuer.
Explore more articles in this category
The edge is fast because it's constrained. This is the decision map for what belongs at the edge, what belongs at origin, and how compute, data, caching, and auth fit together.
Static keys leak. The question isn't if but how fast you notice and how clean your response runbook is when the pager goes off.
Edge code runs in hundreds of PoPs, lives for milliseconds, and gives you no shell. Here's how we get logs, traces, and metrics out of it anyway.
Evergreen posts worth revisiting.