Entra ID Workload Identity Federation: Replacing Secrets with Certificates at Scale
Most Azure tenants accumulate hundreds of client secrets across service principals, with no owner tracking and no rotation discipline. Workload identity federation eliminates this category of risk entirely by replacing stored credentials with OIDC token exchange. This guide covers the migration playbook from secrets to federation across GitHub Actions, Terraform, and AKS at scale.
The Secret Audit Your Security Team Keeps Postponing
Last quarter, an enterprise Azure team ran their first full audit of service principal credentials. They found 847 active client secrets across 312 service principals. 203 secrets were expired but still configured in CI/CD pipelines — the pipelines were using other, newer secrets. 91 secrets had last-used dates more than a year ago. 17 secrets had no owner: the engineers who created them had left the company. None of the secrets showed up in any ITSM ticket or change management record.
This is not a pathological case. This is what most organizations that have been on Azure for three or more years look like. Client secrets accumulate because they are the path of least resistance: create a service principal, generate a secret, paste it in a GitHub Actions variable, and move on. Nobody migrates old workloads unless there is a breach or a compliance audit.
Workload identity federation eliminates this accumulation pattern at the architectural level. Instead of rotating credentials, you eliminate them. The federated credential model uses OIDC token exchange: your CI/CD system (GitHub, GitLab, Azure DevOps) issues a short-lived JWT, Entra ID validates it against a configured trust relationship, and returns an access token. No secret ever touches your pipeline configuration.
---
How Federated Credentials Work
The OIDC token exchange has four steps:
- The workload (GitHub Actions runner, Kubernetes pod, etc.) requests a JWT from its platform's OIDC provider. GitHub Actions uses
https://token.actions.githubusercontent.comas the issuer. The JWT contains claims including the repository, branch, environment, and workflow name. - The workload presents this JWT to the Entra ID token endpoint along with the application (client) ID.
- Entra ID validates the JWT signature against the OIDC provider's JWKS endpoint, then checks the JWT's claims against the configured federated credential conditions: issuer, subject, and audience.
- If validation passes, Entra ID issues an access token for the application. The workload uses this token to call Azure APIs.
The critical point: no credential is stored anywhere. The GitHub Actions secret variables do not contain a password. The Kubernetes pod annotation does not reference a secret. The trust relationship is defined entirely by the OIDC issuer URL and the subject claim pattern.
Application Registration vs Managed Identity
Federated credentials can be configured on two types of Entra ID objects:
| Feature | App Registration + Service Principal | User-Assigned Managed Identity |
|---|---|---|
| Supports federated credentials | Yes | Yes |
| Multi-cloud federation (GitHub, GitLab, etc.) | Yes | Yes |
| Can be granted Azure RBAC roles | Yes | Yes |
| Can be granted Graph API permissions | Yes | Limited |
| Lifecycle management | Manual | Managed by Azure |
| Recommended for external workloads | Yes (GitHub Actions, Terraform Cloud) | Emerging |
| Recommended for AKS workloads | No | Yes (Workload Identity add-on) |
---
Migration Playbook: From Secrets to Federation
Phase 0: Inventory Existing Secrets
Before migrating anything, understand what you have. Run this script to enumerate service principal secrets, their age, and last usage:
# List all service principals with client secrets, showing expiry dates
az ad sp list --all --query "[].{Name:displayName, AppId:appId}" --output tsv | while read NAME APPID; do
CREDS=$(az ad app credential list --id "$APPID" 2>/dev/null | jq -r '.[] | select(.type == "password") | "\(.keyId) expires:\(.endDateTime)"')
if [ -n "$CREDS" ]; then
echo "$NAME | $APPID | $CREDS"
fi
done > sp-secret-inventory.txt
echo "Inventory saved to sp-secret-inventory.txt"This script takes time on large tenants. Run it during off-hours. The output gives you a prioritized migration list: start with secrets expiring in the next 90 days, since those have natural migration windows, and with service principals owned by engineers who have left the organization.
Use KQL to cross-reference recent secret usage against the inventory:
AuditLogs
| where TimeGenerated > ago(90d)
| where OperationType == "Update application - Certificates and secrets management"
| extend AppId = tostring(TargetResources[0].id)
| extend AppName = tostring(TargetResources[0].displayName)
| extend ModifiedBy = tostring(InitiatedBy.user.userPrincipalName)
| project TimeGenerated, AppId, AppName, ModifiedBy, Result
| summarize LastSecretActivity = max(TimeGenerated), ChangeCount = count() by AppId, AppName
| order by LastSecretActivity ascService principals with no activity in 90 days are strong candidates for decommission rather than migration.
Phase 1: New Workloads (Stop the Bleeding)
Enforce a policy that no new CI/CD pipeline is provisioned with a client secret. Every new workload uses federated credentials from day one. Implement this as a Bicep module that the platform team provides and all teams consume:
// platform-workload-identity.bicep
// Standard module for CI/CD service principals. Creates zero client secrets.param appName string
param githubOrg string
param githubRepo string
param githubEnvironment string = ''
param azureRbacRoleId string
param scopeResourceId string
resource appRegistration 'Microsoft.Graph/applications@v1.0' = {
uniqueName: appName
displayName: appName
signInAudience: 'AzureADMyOrg'
}
resource federatedCredential 'Microsoft.Graph/applications/federatedIdentityCredentials@v1.0' = {
name: '${appName}-github-main'
parent: appRegistration
issuer: 'https://token.actions.githubusercontent.com'
subject: githubEnvironment == ''
? 'repo:${githubOrg}/${githubRepo}:ref:refs/heads/main'
: 'repo:${githubOrg}/${githubRepo}:environment:${githubEnvironment}'
audiences: ['api://AzureADTokenExchange']
description: 'GitHub Actions federated credential for ${githubRepo}'
}
resource servicePrincipal 'Microsoft.Graph/servicePrincipals@v1.0' = {
appId: appRegistration.appId
}
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(scopeResourceId, servicePrincipal.id, azureRbacRoleId)
scope: any(scopeResourceId)
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', azureRbacRoleId)
principalId: servicePrincipal.id
principalType: 'ServicePrincipal'
}
}
output clientId string = appRegistration.appId
output tenantId string = tenant().tenantId
The important design point: this module outputs only clientId and tenantId, which are not sensitive and can be stored as non-secret environment variables in GitHub or Azure DevOps.
Phase 2: Migrate Existing Workloads
For each existing workload with a client secret, the migration process has four steps:
- Add a federated credential to the existing app registration without removing the secret
- Update the pipeline to use the federated credential flow
- Validate that the pipeline works end-to-end using federation
- Remove the client secret
The parallel operation in steps 1-3 ensures zero downtime. Only remove the secret after confirming federation works in production. Teams frequently complete step 3 but skip step 4 — detect this with the drift script in the Scale section below.
---
GitHub Actions Configuration
After creating the app registration and federated credential, the GitHub Actions workflow needs three specific changes:
# .github/workflows/deploy.ymlpermissions:
id-token: write # Required: allows GitHub to create OIDC tokens for this workflow
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # Must match the subject claim in the federated credential exactly
steps:
<ul class="list-disc pl-6 mb-4 space-y-2">
<li class="text-gray-600 ml-6">uses: actions/checkout@v4</li>
</ul>
- name: Azure Login via OIDC
<ul class="list-disc pl-6 mb-4 space-y-2">
<li class="text-gray-600 ml-6">name: Deploy Infrastructure</li>
</ul>
run: |
az deployment group create \
--resource-group ${{ vars.RESOURCE_GROUP }} \
--template-file main.bicepThree things that break this flow in production:
Subject claim mismatch: The environment: production in the workflow must match exactly what was configured in the federated credential subject. The subject repo:org/repo:environment:production is different from repo:org/repo:ref:refs/heads/main. Define the subject precisely; Entra ID does exact string matching, not pattern matching.
Missing id-token: write permission: This failure is silent. The workflow does not error at the permissions block; it fails when the login action tries to request the OIDC token and receives a 403. The error message is misleading.
Federated credential on wrong object: The credential must be on the app registration (via az ad app federated-credential create), not on the service principal. These are different commands targeting different resource types.
---
Terraform Configuration
For Terraform Cloud or OpenTofu running outside Azure, federated credentials work the same OIDC mechanism. The setup has two parts: creating the trust in Entra ID, and configuring the azurerm provider to use OIDC:
# Create federated credential for Terraform Cloud workspace
az ad app federated-credential create \
--id <app-registration-object-id> \
--parameters '{
"name": "terraform-cloud-apply",
"issuer": "https://app.terraform.io",
"subject": "organization:<org>:project:<project>:workspace:<workspace>:run_phase:apply",
"audiences": ["api://AzureADTokenExchange"],
"description": "Terraform Cloud apply phase federation"
}'# Create a separate credential for plan phase with read-only RBAC
az ad app federated-credential create \
--id <app-registration-object-id> \
--parameters '{
"name": "terraform-cloud-plan",
"issuer": "https://app.terraform.io",
"subject": "organization:<org>:project:<project>:workspace:<workspace>:run_phase:plan",
"audiences": ["api://AzureADTokenExchange"],
"description": "Terraform Cloud plan phase federation"
}'
The subject claim for Terraform Cloud encodes the organization, project, workspace, and run phase. Creating separate credentials for plan and apply allows you to assign different RBAC roles: Reader for plan, Contributor for apply. This is the closest you can get to least-privilege in Terraform workflows without custom policy.
In the Terraform provider:
# provider.tfterraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
use_oidc = true
client_id = var.azure_client_id # Non-sensitive: workspace variable
tenant_id = var.azure_tenant_id # Non-sensitive: workspace variable
subscription_id = var.azure_subscription_id
# client_secret is absent
}
Set ARM_USE_OIDC=true, ARM_CLIENT_ID, and ARM_TENANT_ID as non-secret workspace variables in Terraform Cloud. The ARM_CLIENT_SECRET environment variable becomes unnecessary and should be explicitly deleted from any workspace that's been migrated.
---
AKS Workload Identity
For pods running in AKS, federated credentials work through the Workload Identity add-on. The pod receives a projected service account token, and the Azure SDK uses it automatically via DefaultAzureCredential.
# Enable OIDC issuer and Workload Identity on an existing AKS cluster
az aks update \
--resource-group <rg> \
--name <cluster-name> \
--enable-oidc-issuer \
--enable-workload-identity# Get the OIDC issuer URL for this cluster
OIDC_ISSUER=$(az aks show \
--resource-group <rg> \
--name <cluster-name> \
--query "oidcIssuerProfile.issuerUrl" \
--output tsv)
# Create a user-assigned managed identity for the workload
az identity create \
--name wi-<workload-name> \
--resource-group <rg>
MI_OBJECT_ID=$(az identity show --name wi-<workload-name> --resource-group <rg> --query principalId --output tsv)
# Link the Kubernetes service account to the managed identity
az identity federated-credential create \
--identity-name wi-<workload-name> \
--resource-group <rg> \
--name k8s-federation \
--issuer "$OIDC_ISSUER" \
--subject "system:serviceaccount:<namespace>:<service-account-name>" \
--audiences '["api://AzureADTokenExchange"]'
# Grant the MI whatever Azure RBAC roles the workload needs
az role assignment create \
--role "Key Vault Secrets User" \
--assignee "$MI_OBJECT_ID" \
--scope /subscriptions/<sub-id>/resourceGroups/<rg>/providers/Microsoft.KeyVault/vaults/<vault>
The Kubernetes manifests:
apiVersion: v1
kind: ServiceAccount
metadata:
name: <service-account-name>
namespace: <namespace>
annotations:
azure.workload.identity/client-id: "<managed-identity-client-id>"
---
apiVersion: apps/v1
kind: Deployment
spec:
template:
metadata:
labels:
azure.workload.identity/use: "true" # Triggers token projection injection
spec:
serviceAccountName: <service-account-name>
containers:
<ul class="list-disc pl-6 mb-4 space-y-2">
<li class="text-gray-600 ml-8">name: app</li>
</ul>
image: <image>
# No env vars with secrets needed — DefaultAzureCredential handles token exchangeThe azure.workload.identity/use: "true" label is not optional: the admission webhook only injects the projected service account volume when it sees this label. Without it, the pod starts successfully but DefaultAzureCredential falls through to the node's system-assigned MI rather than the workload-specific MI. The symptom is authorization failures with unexpected identity in the error message.
---
Scaling Federation to 100+ Service Principals
When managing federation at scale, the operational overhead shifts from secret rotation calendars to service principal catalog hygiene. Three practices reduce that overhead significantly:
Naming convention enforcement: Adopt a convention that encodes the workload, environment, and federation type: sp-. This makes az ad sp list output parseable by scripts and immediately distinguishes federated principals from legacy secret-based ones.
Centralized module library: Store federated credential configurations in a Bicep module library or Terraform module registry. Each team calls the standard module rather than creating credentials manually. This prevents misconfigured subject claims (the most common federation failure mode).
Automated drift detection: Schedule a weekly pipeline that runs this script and alerts on any service principal that completed migration step 1 (add federation) but not step 4 (remove secret):
#!/bin/bash
# Detect service principals with BOTH federated credentials AND active client secrets
echo "Checking for mixed credential state..."
az ad sp list --all --query "[].appId" --output tsv | while read APPID; do
HAS_SECRET=$(az ad app credential list --id "$APPID" 2>/dev/null | jq -r '.[] | select(.type == "password") | .keyId' | head -1)
HAS_FED=$(az ad app federated-credential list --id "$APPID" 2>/dev/null | jq -r '.[0].name' 2>/dev/null)
if [ -n "$HAS_SECRET" ] && [ -n "$HAS_FED" ]; then
APP_NAME=$(az ad app show --id "$APPID" --query displayName --output tsv 2>/dev/null)
echo "MIXED STATE: $APP_NAME ($APPID) has both a client secret and federated credentials"
fi
doneRun this as a scheduled pipeline. Alert output goes to a Teams channel or creates tickets. The goal is to drive the mixed-state count to zero over the migration window.
---
KQL Detection: Catching New Secret Creation
Even after migration, engineers under deadline pressure create new secrets. Set up alerts that catch this before it becomes the next audit finding.
Alert: New Client Secret Created
AuditLogs
| where TimeGenerated > ago(1h)
| where OperationType == "Update application - Certificates and secrets management"
| where ResultDescription contains "password" or ResultDescription contains "Add"
| extend AppId = tostring(TargetResources[0].id)
| extend AppName = tostring(TargetResources[0].displayName)
| extend Actor = tostring(InitiatedBy.user.userPrincipalName)
| extend SecretKeyId = tostring(TargetResources[0].modifiedProperties[0].newValue)
| project TimeGenerated, AppName, AppId, Actor, SecretKeyId
| order by TimeGenerated descAlert threshold: any new password credential created during non-business hours, or by anyone other than the platform team service account. Every new secret creation should require justification.
Alert: Federated Credential Subject Claim Modified
AuditLogs
| where TimeGenerated > ago(1h)
| where OperationType contains "federatedIdentityCredentials"
| where ActivityDisplayName contains "Update" or ActivityDisplayName contains "Delete"
| extend AppName = tostring(TargetResources[0].displayName)
| extend Actor = tostring(InitiatedBy.user.userPrincipalName)
| extend OldSubject = tostring(TargetResources[0].modifiedProperties[0].oldValue)
| extend NewSubject = tostring(TargetResources[0].modifiedProperties[0].newValue)
| project TimeGenerated, AppName, Actor, OldSubject, NewSubjectSubject claim modifications can expand what workloads are trusted to authenticate as a service principal. Alert on any change and require change management approval.
For broader NHI lifecycle management beyond just federated credentials, see the non-human identity security guide. For how federated credentials integrate into a larger zero trust architecture, see the zero trust guide.
---
Hardening Checklist
- [ ] Complete inventory of all service principal client secrets exported, with owner and last-use date documented
- [ ] Platform team Bicep/Terraform module for federated service principals created and published to module registry (zero secrets output)
- [ ] CI/CD platform policy enforced: new pipelines cannot store Azure credentials as secrets; must use federated login action
- [ ] All GitHub Actions workflows updated to
azure/login@v2with OIDC;permissions: id-token: writepresent in every workflow that authenticates - [ ] Federated credential subject claims audited: each matches exactly the expected repository, branch or environment
- [ ] Terraform provider configured with
use_oidc = true;ARM_CLIENT_SECRETabsent from all migrated workspace configurations - [ ] AKS Workload Identity add-on enabled on all clusters; pods use
azure.workload.identity/use: "true"label - [ ] Separate federated credentials created for Terraform plan (Reader) and apply (Contributor) phases
- [ ] Secret rotation calendar removed for any workload migrated to federation
- [ ] Weekly drift detection script deployed as a scheduled pipeline; alerts on mixed credential state
- [ ] KQL alert deployed for new client secret creation on any service principal during non-business hours
- [ ] KQL alert deployed for federated credential subject claim modifications
- [ ] Expired secrets cleaned from all app registrations (expired secrets do not auto-delete in Entra ID)
- [ ] Service principals with no owner documented; decommissioned or ownership transferred
Get weekly security insights
Cloud security, zero trust, and identity guides — straight to your inbox.
Microsoft Cloud Solution Architect
Cloud Solution Architect with deep expertise in Microsoft Azure and a strong background in systems and IT infrastructure. Passionate about cloud technologies, security best practices, and helping organizations modernize their infrastructure.
Questions & Answers
Related Articles
Need Help with Your Security?
Our team of security experts can help you implement the strategies discussed in this article.
Contact Us