Replace Entra ID client secrets with workload identity federation
This guide is for developers replacing app registration client secrets with workload identity federation in Microsoft Entra ID. You’ll create a federated identity credential, update your CI or external workload to request tokens without a stored secret, and verify the token exchange end to end.
TL;DR — Replace the Entra ID app’s client secret with a federated identity credential tied to your external workload’s OIDC issuer, subject, and audience. The most common failure is a mismatch in
issuer,subject, oraudience, which producesAADSTS700213orunauthorized_clientduring token exchange. Reading time: ~5 min
Goal
When you finish, your external workload (for example GitHub Actions, Kubernetes, or another OIDC-capable runner) will get Microsoft Entra ID access tokens for your app registration without any client secret stored in CI, environment variables, or source control.
Prerequisites
- An Entra ID tenant and permission to create or update an app registration and service principal.
- Azure CLI installed:
az --versionshould showazure-cli 2.60+. - Microsoft Graph permissions sufficient to manage app registrations, or portal access to App registrations.
- The external workload’s OIDC details:
issuerURLsubjectclaim value your workload will presentaudiencevalue the workload uses for Entra token exchange
- The Entra tenant ID and the app registration’s client ID.
- If using GitHub Actions: repository admin access and a workflow that can request
id-token: write. - Optional but useful:
jq 1.6+— check withjq --version.
Steps
Step 1: Identify the app registration you are removing the secret from
Run:
APP_NAME="my-ci-app"
az ad app list --display-name "$APP_NAME" --query '[0].{appId:appId,id:id,displayName:displayName}' -o json
You should see JSON with both appId and id; keep appId as CLIENT_ID and id as APP_OBJECT_ID.
Step 2: Inspect and record any existing client secrets before removal
Run:
CLIENT_ID="00000000-0000-0000-0000-000000000000"
az ad app credential list --id "$CLIENT_ID" -o table
You should see zero or more password credentials; if there are active secrets, note which workloads still use them before deleting anything.
⚠️ Deleting the secret before the federated flow works will break any existing automation still using
client_secret. Verify the new flow first, then remove the old secret.
Step 3: Create the federated identity credential in Entra ID
For GitHub Actions, use these literal values as a starting example. Replace ORG, REPO, and branch name.
APP_OBJECT_ID="11111111-1111-1111-1111-111111111111"
cat > fic.json <<'JSON'
{
"name": "github-main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:ORG/REPO:ref:refs/heads/main",
"description": "GitHub Actions main branch",
"audiences": [
"api://AzureADTokenExchange"
]
}
JSON
az ad app federated-credential create --id "$APP_OBJECT_ID" --parameters @fic.json
You should see JSON echoing the federated credential with the exact issuer, subject, and audiences values you supplied.
If you need to inspect it later:
az ad app federated-credential list --id "$APP_OBJECT_ID" -o json
You should see your new credential in the array.
Step 4: Update the external workload to request an OIDC token instead of using a secret
For GitHub Actions, use this workflow shape:
name: deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Azure login with OIDC
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Show signed-in principal
run: az account show --output json
You should see the login step succeed without any client-secret input and az account show should print the expected tenant and subscription.
If you are not using GitHub Actions, the required pattern is the same: your workload must fetch an OIDC JWT from its own identity provider and send that JWT as client_assertion to Entra’s token endpoint with audience api://AzureADTokenExchange configured on the app’s federated credential.
Step 5: Test token exchange directly when debugging
If your platform exposes the raw OIDC token, test Entra token exchange with curl. Replace placeholders with real values.
TENANT_ID="22222222-2222-2222-2222-222222222222"
CLIENT_ID="00000000-0000-0000-0000-000000000000"
OIDC_TOKEN="eyJ..."
SCOPE="https://management.azure.com/.default"
curl -sS -X POST "https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "client_id=${CLIENT_ID}" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "scope=${SCOPE}" \
--data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
--data-urlencode "client_assertion=${OIDC_TOKEN}"
You should see JSON containing token_type, expires_in, and access_token.
A successful response looks like:
{
"token_type": "Bearer",
"expires_in": 3599,
"ext_expires_in": 3599,
"access_token": "eyJ0eXAiOiJKV1QiLCJub25jZSI6..."
}
Step 6: Remove the old client secret after the federated flow passes
List credentials again, then delete the old password credential by key ID.
CLIENT_ID="00000000-0000-0000-0000-000000000000"
az ad app credential list --id "$CLIENT_ID" -o json
KEY_ID="33333333-3333-3333-3333-333333333333"
az ad app credential delete --id "$CLIENT_ID" --key-id "$KEY_ID"
You should get exit code 0; rerunning az ad app credential list should show that key ID is gone.
Verify it works
Run the workload once and verify both login and resource access.
For GitHub Actions, check the workflow log for a successful OIDC login and then run:
az account show --query '{tenantId:tenantId,user:user.name}' -o json
Expected shape:
{
"tenantId": "22222222-2222-2222-2222-222222222222",
"user": "00000000-0000-0000-0000-000000000000"
}
Then prove the old secret is not required by removing any AZURE_CLIENT_SECRET or equivalent secret from the pipeline and rerunning the job. The job should still succeed.
For direct token exchange, decode the JWT claims and confirm the audience and issuer match what you configured:
python3 - <<'PY'
import base64, json, os
jwt = os.environ['OIDC_TOKEN'].split('.')[1]
pad = '=' * (-len(jwt) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(jwt + pad)), indent=2))
PY
Expected: iss equals your configured issuer, sub equals your configured subject, and aud is the value your workload minted for this token.
Common pitfalls
Issuer URL does not match exactly
Mistake: using the right host but the wrong exact issuer string, often with a trailing slash difference.
Symptom: token exchange fails with output like:
{
"error": "invalid_client",
"error_description": "AADSTS700213: No matching federated identity record found for presented assertion issuer 'https://token.actions.githubusercontent.com/'."
}
Fix: update the federated credential so issuer exactly matches the JWT iss claim, character for character.
Subject claim is too broad or just wrong
Mistake: configuring subject as repo:ORG/REPO:* or using a branch/tag/environment value that the workload does not actually emit.
Symptom: Entra rejects the assertion with AADSTS700213 even though the issuer is correct.
Fix: decode one real OIDC token from the workload and copy its exact sub claim into the federated credential.
Audience mismatch between workload and Entra credential
Mistake: the workload requests an OIDC token for one audience, but the Entra federated credential allows only api://AzureADTokenExchange or another different value.
Symptom: unauthorized_client, invalid_client, or a generic token exchange failure.
Fix: set the workload to request the audience you configured, or add the exact audience value to audiences on the federated credential.
Forgot id-token: write in GitHub Actions
Mistake: workflow has Azure login configured but no permission to mint the OIDC token.
Symptom: the login action fails before token exchange, often with log lines saying no OIDC token could be requested.
Fix: add this block at workflow top level or job level:
permissions:
id-token: write
contents: read
Deleted the secret before validating federation
Mistake: removing the old secret first because the federated credential was created successfully.
Symptom: existing deployments fail immediately with invalid_client or missing secret errors while the new flow still has claim mismatches.
Fix: restore or recreate a temporary secret, complete end-to-end verification of federation, then delete the secret in a separate change.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI