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.com as 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) |
For GitHub Actions targeting Azure resources, use an app registration with a service principal. For AKS pods using the Workload Identity add-on, use a user-assigned managed identity.
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().tenantIdThe 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.yml
permissions:
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:
- uses: actions/checkout@v4
- name: Azure Login via OIDC
uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }} # Non-secret: use vars, not secrets
tenant-id: ${{ vars.AZURE_TENANT_ID }} # Non-secret: use vars, not secrets
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Deploy Infrastructure
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.tf
terraform {
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:
- name: app
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-<workload>-<env>-fed. 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](/blog/non-human-identities-nhi-security-guide). For how federated credentials integrate into a larger zero trust architecture, see the [zero trust guide](/blog/what-is-zero-trust-security-complete-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@v2 with OIDC; permissions: id-token: write present 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_SECRET absent 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
Frequently Asked Questions
What is workload identity federation and how does it replace client secrets for CI/CD pipelines?
Workload identity federation uses OIDC (OpenID Connect) to establish trust between an external identity provider, such as GitHub Actions or Azure Kubernetes Service, and an Entra ID service principal. Instead of storing a client secret in a CI/CD pipeline, the pipeline requests a short-lived OIDC token from its own identity provider and exchanges it for an Entra ID access token using the federated credential configuration. No credential is stored anywhere: the exchange happens in real time using a trust relationship configured once on the service principal. This eliminates the rotation, storage, and audit requirements associated with client secrets.
What does the subject claim in a federated credential configuration control and why does it matter for security?
The subject claim is the constraint that limits which specific workloads can use a federated credential to authenticate as a service principal. For GitHub Actions, the subject specifies the repository, branch, environment, or pull request context that is allowed to acquire the identity. For example, a subject of repo:myorg/myrepo:ref:refs/heads/main allows only the main branch of a specific repository to authenticate, not any branch, not any other repository. A misconfigured wildcard or overly broad subject claim can allow unintended workloads to acquire production-level permissions, which is one of the most common federation misconfigurations in enterprise environments.
Why should separate federated credentials be created for Terraform plan and Terraform apply phases?
The plan phase reads infrastructure state and generates a diff, while the apply phase makes actual changes to Azure resources. Giving the plan phase the same permissions as the apply phase means a compromised or malicious pull request pipeline that only deserves read access can actually modify infrastructure. Creating separate credentials with separate role assignments (Reader for plan, Contributor or custom role for apply) enforces least privilege at the pipeline phase level. This also supports a pull request review workflow where plan output is visible to reviewers without any deployment permissions being exercised until after merge.
How does AKS Workload Identity integrate with DefaultAzureCredential in application code?
When a pod is annotated with azure.workload.identity/use: "true" and bound to a Kubernetes service account that has a federated credential configured in Entra ID, the AKS Workload Identity admission webhook injects a projected service account token volume into the pod. DefaultAzureCredential in the Azure SDK automatically detects this projected token file via environment variables set by the webhook and exchanges it for an Azure AD access token using the OIDC federation flow. The application code does not need to handle any credential: calling DefaultAzureCredential() is sufficient, and the underlying token acquisition and refresh is handled transparently by the SDK.
What is the mixed credential state problem during a federation migration and how should it be resolved?
Mixed credential state occurs when a service principal has both a federated credential configuration and an active client secret, typically during the migration window when the federation is configured but the secret has not yet been removed. The risk is that an attacker who obtains the client secret can still authenticate as the service principal even after federation is deployed, because both authentication methods remain valid simultaneously. The mitigation is to run a weekly drift detection script that identifies all service principals with both credential types and alert on any found. The migration is complete only when the client secret is removed from every migrated service principal.
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.
Share this article
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