Open the secrets list of any mature CI/CD pipeline and count the long-lived credentials. There will be a Service Principal client secret for Azure, a service-account JSON file for GCP, an IAM access key for AWS, possibly a personal access token for the Git host. All four are static, all four sit in the CI provider's secret store, all four expire on the day someone forgets to rotate them - and any one of them in the wrong hands is full deployment access to your environment. Workload identity federation deletes the entire category. The pipeline presents a short-lived OIDC token signed by the CI provider, the cloud's identity service validates the token against a federation trust you configured once, and the cloud returns a short-lived access token. Nothing long-lived is stored anywhere. This guide walks the federated-credentials setup for the most common pair - GitHub Actions deploying into Azure - shows the equivalents for Azure DevOps, GitLab and AWS, and covers the troubleshooting you will hit. It ships a free PDF cheat sheet of the exact commands.
Table of Contents
- Why client secrets in CI are a permanent risk
- How OIDC federation actually works
- GitHub Actions to Azure - the canonical setup
- Subject claim - the part everyone gets wrong
- The workflow YAML
- Other CI providers and clouds
- RBAC - the federation does not grant anything
- Rotation - what actually rotates
- Auditing the federated credential
- Common pitfalls
- Audit checklist
- FAQ
Why client secrets in CI are a permanent risk
A Service Principal client secret stored in GitHub Secrets is, from a threat-model perspective, a long-lived bearer token sitting in the secret store of a system you do not own. It is replicated to every runner that pulls the secret. It is logged by careless steps. It is inherited by forks if the action of writing it to an environment variable runs in pull_request_target. It does not expire silently - it expires loudly during a deploy at 17:55 on a Friday because someone set the maximum lifetime to two years.
OIDC federation removes the secret. The pipeline still authenticates, but the credential is a fresh JWT signed by the CI provider for this run, with a subject claim that names this exact workflow on this exact branch of this exact repository. Entra ID (or the equivalent on AWS/GCP) verifies the signature against the CI provider's published JWKS, checks the federation rule (issuer + audience + subject), and returns a 60-minute access token. There is nothing to rotate. There is nothing for an attacker to steal from the secrets store - because nothing is in it.
How OIDC federation actually works
Three pieces have to agree:
- The OIDC provider (GitHub Actions, Azure DevOps, GitLab, Bitbucket - all expose
https://<provider>/.well-known/openid-configuration). The provider mints a JWT for each workflow run, signed with a key listed in its JWKS endpoint, with claims that describe the run. - The relying party (Entra ID for Azure, AWS STS for AWS, GCP IAM for GCP). The relying party trusts the OIDC provider's JWKS and accepts a JWT whose claims match a federation rule you configured.
- The identity in the cloud (a Service Principal in Azure, an IAM role in AWS, a service account in GCP). Once the JWT is verified and the federation rule matches, the relying party issues a token for this identity. RBAC on that identity controls what it can do.
The federation rule is the new piece. It says "if a JWT comes from https://token.actions.githubusercontent.com with audience api://AzureADTokenExchange and subject repo:dargslan/dargslan-php:ref:refs/heads/main, treat it as our prod-deployer Service Principal". Anything else - a different repo, a different branch, a pull request - does not match the rule and gets nothing.
GitHub Actions to Azure - the canonical setup
Three commands set up the trust on the Azure side. Run them once per repo (or per environment) using an admin credential:
# 1. Create or reuse a Service Principal (no secret!)
az ad sp create-for-rbac --name "dargslan-github-prod" --skip-assignment
# Capture appId from the output, then:
APP_ID="00000000-0000-0000-0000-000000000000"
SP_OBJECT_ID=$(az ad sp show --id $APP_ID --query id -o tsv)
# 2. Create the federated credential
az ad app federated-credential create --id $APP_ID --parameters '{
"name": "github-main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:dargslan/dargslan-php:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'
# 3. Grant RBAC - what is the SP allowed to do?
az role assignment create \
--assignee $SP_OBJECT_ID \
--role "Contributor" \
--scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/rg-prod"
That is the entire Azure-side setup. No client secret was created. The federated-credential record is what makes Entra ID accept GitHub's OIDC token for this Service Principal.
Subject claim - the part everyone gets wrong
The subject claim is how the federation rule narrows trust to a specific workflow context. The format from GitHub is well-defined and surprisingly easy to mis-specify:
repo:<owner>/<repo>:ref:refs/heads/<branch>- any push to that branchrepo:<owner>/<repo>:ref:refs/tags/<tag>- a specific tagrepo:<owner>/<repo>:environment:<env-name>- workflows targeting a GitHub environment (the safest pattern - environments support reviewers and protection rules)repo:<owner>/<repo>:pull_request- any pull request (almost never what you want; pull requests run untrusted code)
The recommended pattern: one federated credential per environment, with the subject pinned to repo:owner/repo:environment:prod. The GitHub-side environment can require manual approval, restrict which branches can deploy to it, and block deletion. The Azure-side federation only accepts JWTs that name that environment in the subject claim. The two together provide a credential that cannot be stolen and a workflow that cannot deploy without an approver.
The workflow YAML
The workflow needs two things: id-token: write permission so the runner can mint the OIDC token, and the azure/login action with the SP details. No secret is referenced:
name: Deploy to prod
on:
push:
branches: [main]
permissions:
id-token: write # mint the OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: prod # ties to the federated credential subject
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- run: az group list --query "[].name" -o tsv
- run: az webapp deploy --name dargslan-web --resource-group rg-prod --src-path build.zip
Three values - client ID, tenant ID, subscription ID - all of them not secret. The vars context is the right place: anyone can read them, nobody can do anything with them without a matching OIDC token from this exact repo + environment. The repository's secrets store no longer needs AZURE_CLIENT_SECRET at all; delete it.
Other CI providers and clouds
The pattern is identical, the strings change. Azure DevOps to Azure: use a service connection of type "Workload Identity federation"; Azure DevOps creates the Service Principal and federated credential automatically. GitLab to AWS: configure an OIDC identity provider in IAM with the GitLab issuer, then a role with a trust policy that accepts sub: project_path:dargslan/dargslan-php:ref_type:branch:ref:main. GitHub Actions to AWS: same, with subject repo:owner/repo:ref:refs/heads/main. GitHub Actions to GCP: configure a workload identity pool and provider, link a service account, set the subject to the repo + ref.
All four use the same conceptual model - OIDC issuer, JWKS verification, subject-claim matching, exchange for cloud-specific token. The provider docs differ in command syntax, not in concept.
RBAC - the federation does not grant anything
A common surprise: setting up the federated credential allows authentication; it does not grant any access. The RBAC on the Service Principal (or IAM role, or GCP service account) is what controls what the workflow can do once authenticated. Scope it tightly. Contributor on a single resource group is right for most app deployments; Owner at the subscription level is almost never right. Use Azure resource locks, AWS SCPs and GCP organisation policies as the second layer that survives a misconfigured RBAC role.
Rotation - what actually rotates
Three things rotate, all automatically:
- The OIDC token from the CI provider - minted fresh per workflow run, valid for ~10 minutes. Nothing for you to do.
- The cloud access token - minted on each
azure/loginstep, valid for 60 minutes. Nothing for you to do. - The CI provider's JWKS signing keys - rotated on the provider's schedule. Entra ID / AWS STS / GCP fetch the new keys automatically on a cache TTL.
The federation trust itself - the JSON object that names the issuer, audience and subject - does not need rotation. It is descriptive, not secret.
Auditing the federated credential
Two log streams matter. The cloud-side sign-in log records every successful federated authentication: which Service Principal, from which issuer, with which subject claim, at what time. In Entra ID this is Sign-in logs > Service principal sign-ins > Authentication method = federation. The CI-side audit log records every workflow run, with the sha, the actor, and the environment. Joining them gives you the full provenance: commit X by user Y on branch Z, run R, produced subject S, exchanged for token T, deployed change C.
Common pitfalls
- Subject claim mismatch. The most common error - off-by-one in the branch name, missing
refs/heads/prefix, wrong environment name. The Entra error isAADSTS70021and it tells you the actual subject the token presented; copy-paste it into the federated-credential record. - Forgetting
permissions: id-token: writein the workflow. Without it, the OIDC token mint fails beforeazure/logineven runs. - Using
pull_requestas the subject. Pull requests run untrusted code from forks; granting them deploy permissions is a critical security mistake. - Leaving the old client secret in place. The federated credential is additive - the old secret keeps working until you delete it. Delete it explicitly after the federated path is verified.
- Over-broad RBAC. Federation is the authentication layer; RBAC is what controls power. Both must be tight.
Audit checklist
- No CI secret references a long-lived cloud credential (1 pt)
- Federated credential subject pins to a specific environment, not
pull_request(1 pt) - RBAC on the Service Principal scoped to a single resource group (1 pt)
- Old client secrets deleted from Entra ID after federation rollout (1 pt)
- Federation sign-ins forwarded to the SIEM and reviewed weekly (1 pt)
5/5 = PASS, 3-4 = WARN, <3 = FAIL.
FAQ
Does this work with self-hosted runners?
Yes. The OIDC token is minted by GitHub's control plane, not the runner; the runner only relays the token to Azure. Self-hosted runners work identically.
What if my CI provider does not expose OIDC?
Most major ones do (GitHub Actions, GitLab, Bitbucket, Azure DevOps, CircleCI, Jenkins via OIDC plugin). For one that does not, a managed identity on the runner host is the next-best option; bare client secrets are last.
Can multiple repos share one Service Principal?
Yes - add multiple federated-credential records to the same SP, one per (repo, environment) pair. Or one SP per environment, more granular RBAC. Pick the tradeoff intentionally.
Does this work for Terraform / Pulumi / OpenTofu?
Yes. The IaC tool inherits the Azure CLI's authenticated session after azure/login succeeds. No tool-specific provider config needed.
How do I roll back if something breaks?
Re-add the client secret to the secrets store and revert the workflow to use creds: instead of OIDC. Keep the federation in place; deletion is a separate step once the OIDC path is proven.