The most dangerous line I've seen in pipelines over the years is AZURE_CLIENT_SECRET stored as a pipeline variable. It works. The deploy goes through. And in that exact moment you have a long-lived credential in your system that nobody rotates, that leaks into logs, into env, into the runner's memory, and into the head of everyone who once copied it into a local .env.
OIDC Workload Identity Federation doesn't solve this with better secret storage. It solves it by removing the secret entirely.
Why long-lived keys are a liability
A service principal secret or an access key has one property that makes it dangerous: it lives long and it travels. You generate it once, set the expiry to a year (or two, let's be honest), and from then on it's a static string that grants full cloud access to anyone who holds it.
Rotation is manual and everyone hates it. Auditing is impossible — when a secret leaks, you don't know until someone does damage. And the blast radius is enormous, because the same key is usually shared by both the dev and prod pipeline.
How federation actually works
The principle is a trust-based token exchange between two identity providers. No secret ever passes through the pipeline.
- The pipeline (GitHub Actions, Azure DevOps) requests a short-lived ID token from its own OIDC provider — a signed JWT.
- That JWT carries claims describing who is asking and from where:
iss(issuer — who minted the token),sub(subject — the repo, branch, tag, or environment), andaud(audience — who the token is intended for). - The pipeline presents this JWT to the cloud IdP (Microsoft Entra ID, AWS STS).
- The cloud validates the token's signature against the issuer's public keys, checks that
iss+sub+audexactly match a pre-configured trust, and if so, exchanges the JWT for a short-lived access token.
That access token lives for minutes, not years. Once the job ends it's worthless. Nothing to rotate, nothing to leak.
GitHub Actions → Azure
On the GitHub side you need one thing people still forget — the permission for the job to request an OIDC token:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
No secret. Just IDs, which aren't sensitive.
On the Azure side you create a federated credential on the app registration (or managed identity). There you define the trust:
- Issuer:
https://token.actions.githubusercontent.com - Subject: e.g.
repo:my-org/my-repo:ref:refs/heads/mainorrepo:my-org/my-repo:environment:production - Audience:
api://AzureADTokenExchange(the default)
On each request Entra ID checks that the sub in the incoming JWT exactly matches what you configured. The issuer + subject combination must be unique on the app.
Azure DevOps, and a note on AWS
In Azure DevOps this is handled by the Workload Identity Federation service connection (GA since February 2024). Instead of a stored secret, the service connection has its own federated trust — the pipeline requests a token from vstoken.dev.azure.com and exchanges it.
With AWS the principle is identical, only the terms differ. In IAM you create an OIDC identity provider (for GitHub: issuer token.actions.githubusercontent.com, audience sts.amazonaws.com) and an IAM role with a trust policy that allows the sts:AssumeRoleWithWebIdentity action and checks sub in its condition. The pipeline, via aws-actions/configure-aws-credentials, exchanges the JWT for temporary STS credentials.
Gotchas that will cost you an evening
The subject claim format. This is the source of 80% of the pain. ref:refs/heads/main is not the same as environment:production. If your workflow uses a GitHub Environment, the subject changes and your federated credential stops matching. For each branch / environment / tag you need either a separate trust or flexible federated credentials with a wildcard.
no matching federated identity record found. The classic. It means one of three things: id-token: write is missing in that specific job, the sub doesn't match to the character, or the aud doesn't match. Fastest diagnosis — print the token's actual claims in the pipeline and compare them character by character with the cloud-side config.
Audience mismatch. The default api://AzureADTokenExchange is for public Azure. For AWS it's sts.amazonaws.com. For sovereign clouds the values differ. Never guess them.
TTL. The token lives for minutes. Long deploys that request the token up front and use it an hour later will fail. Request it as close to the point of use as possible.
What changes operationally
After moving to federation, secret rotation disappears as a discipline — there's nothing to rotate. Least privilege finally becomes real, because you bind each trust to a specific repo, branch, and environment rather than to one shared key. And when someone forks the repo or runs a workflow from the wrong branch, the trust simply doesn't match and access is denied.
The best secret is the one that doesn't exist. OIDC federation is exactly that.