Replace Entra ID Client Secrets with Workload Identity Federation
For developers replacing app registration client secrets in CI/CD or Kubernetes with short-lived federated tokens in Microsoft Entra ID. You will create a federated credential, update your workload to request tokens from its external identity provider, and verify that secretless sign-in works end to end.
TL;DR — Replace the Entra ID app's client secret with a federated identity credential tied to your external workload issuer/subject, then have the workload request an Entra token using its external OIDC token instead of
client_secret. The most common failure is an exact-string mismatch inissuer,subject, oraudiences; copy those values literally from your CI/Kubernetes provider and the federated credential. Reading time: ~5 min
Goal
When you finish, your workload signs in to Microsoft Entra ID without a stored client secret: the Entra app registration has a federated identity credential, your CI/CD job or Kubernetes pod exchanges its external OIDC token for an Entra access token, and az login or a direct token request succeeds without client_secret.
Prerequisites
- An Entra tenant and permission to create or edit an app registration; one of: Application Administrator, Cloud Application Administrator, or equivalent delegated rights
- Azure CLI 2.60+ installed — check with:
az version --query '"azure-cli"' -o tsv
jq1.6+ — check with:
jq --version
- One external OIDC-capable workload identity provider already issuing tokens to your workload, such as GitHub Actions or Kubernetes service account tokens
- The exact values for your external token's
iss,sub, andaudclaims - Your Entra tenant ID, subscription ID, and either an existing app registration or permission to create one
- If using Kubernetes:
kubectlaccess to the cluster and namespace hosting the workload
Steps
Step 1: Create or identify the Entra app registration
If you already have an app registration used with a client secret, reuse it. Otherwise create one:
APP_NAME="my-wif-app"
az login
az account set --subscription "<subscription-id>"
az ad app create --display-name "$APP_NAME" --query '{appId:appId,id:id}' -o json
Example output shape:
{
"appId": "11111111-2222-3333-4444-555555555555",
"id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
}
You should see both an appId (client ID) and id (object ID).
Step 2: Remove dependency on the existing client secret
If your workload currently exports AZURE_CLIENT_SECRET or passes --client-secret, stop using it in code and pipeline config before deleting the secret.
For a shell-based workload, replace secret-based login:
az login --service-principal \
--username "$AZURE_CLIENT_ID" \
--tenant "$AZURE_TENANT_ID" \
--federated-token "$AZURE_FEDERATED_TOKEN_FILE_CONTENTS"
If your workload reads the token from a file, use:
az login --service-principal \
--username "$AZURE_CLIENT_ID" \
--tenant "$AZURE_TENANT_ID" \
--federated-token "$(cat "$AZURE_FEDERATED_TOKEN_FILE")"
You should see [] or a subscription list from az login instead of a prompt for a secret.
Step 3: Inspect the external OIDC token claims
Get a real token from your workload environment and inspect the claims. For any JWT-like token:
TOKEN="<paste-oidc-token-here>"
printf '%s' "$TOKEN" | awk -F. '{print $2}' | base64 -d 2>/dev/null | jq .
Example output shape:
{
"iss": "https://token.actions.githubusercontent.com",
"sub": "repo:org/repo:ref:refs/heads/main",
"aud": "api://AzureADTokenExchange",
"exp": 1780000000
}
You should see exact iss, sub, and aud values; copy them literally.
Step 4: Add the federated identity credential to the app
Create a JSON file with the exact issuer, subject, and audience from Step 3.
For GitHub Actions, a common example is:
{
"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"
]
}
Save that as fic.json, then run:
APP_OBJECT_ID="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
az ad app federated-credential create --id "$APP_OBJECT_ID" --parameters @fic.json
For Kubernetes, use your cluster's issuer URL and the service account subject format:
{
"name": "k8s-prod-api",
"issuer": "https://<your-cluster-oidc-issuer>",
"subject": "system:serviceaccount:prod:api",
"description": "prod/api service account",
"audiences": [
"api://AzureADTokenExchange"
]
}
You should see the created federated credential echoed back as JSON.
Step 5: Grant the app the Azure permissions it actually needs
If the app needs Azure resource access, assign an Azure RBAC role to the service principal. Create the service principal if it does not exist:
APP_ID="11111111-2222-3333-4444-555555555555"
SP_ID=$(az ad sp create --id "$APP_ID" --query id -o tsv)
az role assignment create \
--assignee-object-id "$SP_ID" \
--assignee-principal-type ServicePrincipal \
--role "Reader" \
--scope "/subscriptions/<subscription-id>/resourceGroups/<resource-group>"
You should see a role assignment JSON object with roleDefinitionName set to the role you chose.
Step 6: Update the workload to request and use the federated token
For GitHub Actions, use OIDC and Azure Login without a client secret:
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: 11111111-2222-3333-4444-555555555555
tenant-id: 66666666-7777-8888-9999-000000000000
subscription-id: <subscription-id>
For Kubernetes, project the service account token and exchange it with Entra in your container:
apiVersion: v1
kind: ServiceAccount
metadata:
name: api
namespace: prod
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: prod
spec:
replicas: 1
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
serviceAccountName: api
containers:
- name: api
image: ghcr.io/example/api:latest
env:
- name: AZURE_CLIENT_ID
value: "11111111-2222-3333-4444-555555555555"
- name: AZURE_TENANT_ID
value: "66666666-7777-8888-9999-000000000000"
- name: AZURE_FEDERATED_TOKEN_FILE
value: "/var/run/secrets/tokens/azure-identity-token"
volumeMounts:
- name: azure-identity-token
mountPath: /var/run/secrets/tokens
readOnly: true
volumes:
- name: azure-identity-token
projected:
sources:
- serviceAccountToken:
path: azure-identity-token
expirationSeconds: 3600
audience: api://AzureADTokenExchange
You should see the workload start without any client secret is missing or invalid_client errors.
Step 7: Delete the old client secret after successful cutover
⚠️ Deleting the secret before verifying federation will break any workload still using it.
List existing secrets:
APP_OBJECT_ID="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
az ad app credential list --id "$APP_OBJECT_ID" -o table
Delete the old secret by key ID:
az ad app credential delete --id "$APP_OBJECT_ID" --key-id "<key-id>"
You should see no output and an exit code of 0.
Verify it works
From the workload environment, request an Entra access token without any client secret.
With Azure CLI already installed:
az login --service-principal \
--username "$AZURE_CLIENT_ID" \
--tenant "$AZURE_TENANT_ID" \
--federated-token "$(cat "$AZURE_FEDERATED_TOKEN_FILE")"
az account get-access-token --resource https://management.azure.com/ --query '{tenant:tenant,expiresOn:expiresOn,tokenType:tokenType}' -o json
Expected output shape:
{
"expiresOn": "2026-08-05 14:22:31.000000",
"tenant": "66666666-7777-8888-9999-000000000000",
"tokenType": "Bearer"
}
If you want to test the raw token exchange endpoint directly:
TENANT_ID="66666666-7777-8888-9999-000000000000"
CLIENT_ID="11111111-2222-3333-4444-555555555555"
FED_TOKEN=$(cat "$AZURE_FEDERATED_TOKEN_FILE")
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 "scope=https://management.azure.com/.default" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
--data-urlencode "client_assertion=$FED_TOKEN" | jq .
Expected success shape includes access_token, token_type, and expires_in.
Common pitfalls
issuer does not exactly match the token's iss
Mistake: using the provider's docs URL or adding/removing a trailing slash from the issuer.
Symptom: token exchange fails with AADSTS70021: No matching federated identity record found for presented assertion.
Fix: decode a real token and copy the iss claim exactly into fic.json, then recreate the federated credential.
subject is too broad or simply wrong
Mistake: creating a subject for main while the job runs from a tag, pull request, different environment, or different Kubernetes namespace/service account.
Symptom: the same app works in one pipeline/job but fails in another with AADSTS70021.
Fix: inspect the failing job's actual token and set subject to that exact sub claim, or create additional federated credentials for each allowed subject.
Wrong audience in the external token
Mistake: the workload requests an OIDC token with an audience other than api://AzureADTokenExchange while the federated credential expects that audience.
Symptom: invalid_client or AADSTS50013: Assertion failed signature validation / no matching record depending on provider flow.
Fix: request the external token with audience api://AzureADTokenExchange, or update the federated credential audiences to match the token's aud exactly.
Service principal exists but has no RBAC assignment
Mistake: federation is configured correctly, but the app has no Azure role on the target scope.
Symptom: az login succeeds, but later commands fail with AuthorizationFailed or HTTP 403.
Fix: run az role assignment create --assignee-object-id "$SP_ID" --role "<role>" --scope "<scope>" for the required subscription, resource group, or resource.
Deleting the client secret before all runners/pods are updated
Mistake: removing the secret immediately after adding federation.
Symptom: some jobs succeed and others fail with Failed to authenticate since no client secret was provided or old SDK config errors.
Fix: deploy the workload change first, verify federation from every environment, then delete the secret.
Clock skew or expired projected token in Kubernetes
Mistake: long-running pod uses an expired service account token, or node time is badly skewed.
Symptom: intermittent AADSTS700024: Client assertion is not within its valid time range.
Fix: restart the pod to refresh the projected token and fix node time sync; keep expirationSeconds reasonable, such as 3600.
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