Cyber Intelligence

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.

I
Microsoft Cloud Solution Architect
Entra IDWorkload IdentityFederated CredentialsGitHub ActionsTerraformAKSZero TrustService Principal

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:

  1. 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.
  2. The workload presents this JWT to the Entra ID token endpoint along with the application (client) ID.
  3. 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.
  4. 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:

FeatureApp Registration + Service PrincipalUser-Assigned Managed Identity
Supports federated credentialsYesYes
Multi-cloud federation (GitHub, GitLab, etc.)YesYes
Can be granted Azure RBAC rolesYesYes
Can be granted Graph API permissionsYesLimited
Lifecycle managementManualManaged by Azure
Recommended for external workloadsYes (GitHub Actions, Terraform Cloud)Emerging
Recommended for AKS workloadsNoYes (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 asc

Service 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:

  1. Add a federated credential to the existing app registration without removing the secret
  2. Update the pipeline to use the federated credential flow
  3. Validate that the pipeline works end-to-end using federation
  4. 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: <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
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 }}
<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.bicep

Three 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:
<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 exchange

The 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---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
done

Run 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 desc

Alert 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, NewSubject

Subject 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@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

Get weekly security insights

Cloud security, zero trust, and identity guides — straight to your inbox.

I

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