Service principals are identity objects in Azure Active Directory (Microsoft Entra ID) that applications use to authenticate and access Azure resources. Unlike user accounts, service principals use credentials (secrets or certificates) that can be easily leaked, exposed in code, or compromised. Regular credential rotation is essential for maintaining security. This guide covers rotation strategies, automation, and the transition to managed identities.
This article is part of our comprehensive cloud security tips guide, focusing specifically on Azure service principal security.
Overview
Service principal security requires attention to:
- Credential types: Understanding secrets vs. certificates
- Rotation frequency: Balancing security with operational burden
- Zero-downtime rotation: Updating credentials without service interruption
- Managed identities: Eliminating credential management where possible
- Monitoring: Detecting credential misuse and expiration
Prerequisites
Before implementing credential rotation, ensure you have:
- Application Administrator or Cloud Application Administrator role in Azure AD
- Azure CLI (2.50.0+) with signed-in session
- Access to applications that use the service principal
- Understanding of application deployment processes
- Key Vault access (for secure credential storage)
Part 1: Inventory Existing Service Principals
List All Service Principals
# List all service principals in your tenant
az ad sp list \
--all \
--query "[?servicePrincipalType=='Application'].{Name:displayName, AppId:appId, ObjectId:id}" \
--output table
# Filter to find service principals with expiring credentials
az ad sp list \
--all \
--query "[?servicePrincipalType=='Application']" | \
jq -r '.[] | select(.passwordCredentials != null) |
{name: .displayName, appId: .appId,
credentials: [.passwordCredentials[] | {id: .keyId, expiry: .endDateTime}]}'
Check Credential Expiration
# Get credentials for a specific service principal
az ad sp credential list \
--id "your-service-principal-app-id" \
--query "[].{KeyId:keyId, Type:type, StartDate:startDateTime, EndDate:endDateTime}" \
--output table
# Find credentials expiring within 30 days
EXPIRY_DATE=$(date -d "+30 days" +%Y-%m-%d)
az ad sp list --all --query "[?servicePrincipalType=='Application']" | \
jq -r --arg expiry "$EXPIRY_DATE" '.[] |
select(.passwordCredentials != null) |
select(.passwordCredentials[] | .endDateTime < $expiry) |
{name: .displayName, appId: .appId, expiringCreds: [.passwordCredentials[] | select(.endDateTime < $expiry) | .endDateTime]}'
Using PowerShell
# Connect to Microsoft Graph
Connect-MgGraph -Scopes "Application.Read.All"
# Get all app registrations with credential expiry info
$apps = Get-MgApplication -All
$expiringCredentials = foreach ($app in $apps) {
$expiringSoon = $app.PasswordCredentials | Where-Object {
$_.EndDateTime -lt (Get-Date).AddDays(30)
}
if ($expiringSoon) {
[PSCustomObject]@{
AppName = $app.DisplayName
AppId = $app.AppId
ExpiringCredentials = $expiringSoon | ForEach-Object {
[PSCustomObject]@{
KeyId = $_.KeyId
EndDate = $_.EndDateTime
DaysRemaining = ($_.EndDateTime - (Get-Date)).Days
}
}
}
}
}
$expiringCredentials | Format-Table -AutoSize
Part 2: Rotate Client Secrets
Manual Secret Rotation
Step 1: Add New Secret (Keep Old Active)
# Add new secret with 90-day expiry
NEW_SECRET=$(az ad sp credential reset \
--id "your-service-principal-app-id" \
--append \
--years 0 \
--end-date $(date -d "+90 days" +%Y-%m-%d) \
--query password -o tsv)
echo "New secret: $NEW_SECRET"
# IMPORTANT: Store this securely immediately - it won't be shown again
Step 2: Store New Secret in Key Vault
# Store in Azure Key Vault
az keyvault secret set \
--vault-name "kv-app-secrets" \
--name "sp-myapp-secret" \
--value "$NEW_SECRET" \
--expires $(date -d "+90 days" +%Y-%m-%dT%H:%M:%SZ)
# Add metadata tags
az keyvault secret set-attributes \
--vault-name "kv-app-secrets" \
--name "sp-myapp-secret" \
--tags "service-principal=myapp" "rotation-date=$(date +%Y-%m-%d)"
Step 3: Update Applications
Update your applications to use the new secret from Key Vault:
# Python example using Azure SDK
from azure.identity import ClientSecretCredential
from azure.keyvault.secrets import SecretClient
# Fetch secret from Key Vault
kv_uri = "https://kv-app-secrets.vault.azure.net/"
secret_client = SecretClient(vault_url=kv_uri, credential=DefaultAzureCredential())
sp_secret = secret_client.get_secret("sp-myapp-secret").value
# Use the secret for service principal authentication
credential = ClientSecretCredential(
tenant_id="your-tenant-id",
client_id="your-service-principal-app-id",
client_secret=sp_secret
)
Step 4: Verify and Remove Old Secret
# List all credentials
az ad sp credential list \
--id "your-service-principal-app-id" \
--output table
# After confirming new secret works, remove old credential
az ad sp credential delete \
--id "your-service-principal-app-id" \
--key-id "old-credential-key-id"
Automated Secret Rotation with Azure Automation
# Azure Automation Runbook for secret rotation
param(
[Parameter(Mandatory=$true)]
[string]$ServicePrincipalAppId,
[Parameter(Mandatory=$true)]
[string]$KeyVaultName,
[Parameter(Mandatory=$true)]
[string]$SecretName,
[int]$ExpiryDays = 90
)
# Connect using managed identity
Connect-AzAccount -Identity
# Connect to Microsoft Graph
Connect-MgGraph -Identity
# Generate new secret
$endDate = (Get-Date).AddDays($ExpiryDays)
$passwordCredential = @{
displayName = "AutoRotated-$(Get-Date -Format 'yyyyMMdd')"
endDateTime = $endDate
}
$newCredential = Add-MgApplicationPassword -ApplicationId $ServicePrincipalAppId -PasswordCredential $passwordCredential
# Store in Key Vault
$secret = ConvertTo-SecureString $newCredential.SecretText -AsPlainText -Force
Set-AzKeyVaultSecret -VaultName $KeyVaultName -Name $SecretName -SecretValue $secret -Expires $endDate
Write-Output "New secret created and stored in Key Vault"
Write-Output "Secret expires: $endDate"
Write-Output "Key ID: $($newCredential.KeyId)"
# Clean up old credentials (keep last 2)
$allCredentials = Get-MgApplication -ApplicationId $ServicePrincipalAppId |
Select-Object -ExpandProperty PasswordCredentials |
Sort-Object EndDateTime -Descending
if ($allCredentials.Count -gt 2) {
$credentialsToRemove = $allCredentials | Select-Object -Skip 2
foreach ($cred in $credentialsToRemove) {
Remove-MgApplicationPassword -ApplicationId $ServicePrincipalAppId -KeyId $cred.KeyId
Write-Output "Removed old credential: $($cred.KeyId)"
}
}
Part 3: Rotate Certificates
Certificates provide stronger security than secrets.
Generate and Upload New Certificate
Step 1: Generate Certificate
# Generate self-signed certificate (for dev/test)
openssl req -x509 \
-newkey rsa:2048 \
-keyout sp-key.pem \
-out sp-cert.pem \
-days 365 \
-nodes \
-subj "/CN=myapp-service-principal"
# Create PFX for Windows applications
openssl pkcs12 -export \
-out sp-cert.pfx \
-inkey sp-key.pem \
-in sp-cert.pem \
-passout pass:
# For production, use Azure Key Vault to generate certificates
az keyvault certificate create \
--vault-name "kv-app-certs" \
--name "sp-myapp-cert" \
--policy @cert-policy.json
cert-policy.json:
{
"issuerParameters": {
"name": "Self"
},
"keyProperties": {
"exportable": true,
"keySize": 2048,
"keyType": "RSA",
"reuseKey": false
},
"secretProperties": {
"contentType": "application/x-pkcs12"
},
"x509CertificateProperties": {
"keyUsage": ["digitalSignature", "keyEncipherment"],
"subject": "CN=myapp-service-principal",
"validityInMonths": 12
},
"lifetimeActions": [
{
"trigger": {
"daysBeforeExpiry": 30
},
"action": {
"actionType": "AutoRenew"
}
}
]
}
Step 2: Upload Certificate to Service Principal
# Upload certificate (append to existing)
az ad sp credential reset \
--id "your-service-principal-app-id" \
--cert @sp-cert.pem \
--append
# Or upload from Key Vault
CERT_VALUE=$(az keyvault certificate show \
--vault-name "kv-app-certs" \
--name "sp-myapp-cert" \
--query "cer" -o tsv)
az ad sp credential reset \
--id "your-service-principal-app-id" \
--cert "$CERT_VALUE" \
--append
Step 3: Update Application Configuration
# Python example using certificate authentication
from azure.identity import CertificateCredential
credential = CertificateCredential(
tenant_id="your-tenant-id",
client_id="your-service-principal-app-id",
certificate_path="/path/to/sp-cert.pem"
)
# Or with PFX
credential = CertificateCredential(
tenant_id="your-tenant-id",
client_id="your-service-principal-app-id",
certificate_path="/path/to/sp-cert.pfx",
password="pfx-password" # If password-protected
)
Part 4: Migrate to Managed Identities
Managed identities eliminate credential management entirely.
System-Assigned Managed Identity
# Enable on Azure VM
az vm identity assign \
--name myvm \
--resource-group rg-security
# Enable on App Service
az webapp identity assign \
--name mywebapp \
--resource-group rg-security
# Enable on Azure Function
az functionapp identity assign \
--name myfunctionapp \
--resource-group rg-security
# Get the principal ID
PRINCIPAL_ID=$(az vm identity show \
--name myvm \
--resource-group rg-security \
--query principalId -o tsv)
# Grant permissions using RBAC
az role assignment create \
--role "Storage Blob Data Contributor" \
--assignee-object-id $PRINCIPAL_ID \
--scope "/subscriptions/{sub-id}/resourceGroups/rg-security/providers/Microsoft.Storage/storageAccounts/mystorageaccount"
User-Assigned Managed Identity
For sharing identity across multiple resources:
# Create user-assigned managed identity
az identity create \
--name mi-shared-app-identity \
--resource-group rg-security \
--location eastus
# Get identity details
MI_ID=$(az identity show \
--name mi-shared-app-identity \
--resource-group rg-security \
--query id -o tsv)
MI_CLIENT_ID=$(az identity show \
--name mi-shared-app-identity \
--resource-group rg-security \
--query clientId -o tsv)
MI_PRINCIPAL_ID=$(az identity show \
--name mi-shared-app-identity \
--resource-group rg-security \
--query principalId -o tsv)
# Assign to multiple resources
az vm identity assign \
--name myvm1 \
--resource-group rg-security \
--identities $MI_ID
az webapp identity assign \
--name mywebapp \
--resource-group rg-security \
--identities $MI_ID
Update Application Code
# Python SDK with managed identity (no credentials needed!)
from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobServiceClient
# DefaultAzureCredential automatically uses managed identity when running in Azure
credential = DefaultAzureCredential()
blob_service = BlobServiceClient(
account_url="https://mystorageaccount.blob.core.windows.net",
credential=credential
)
# Works seamlessly - no secrets to manage!
container_client = blob_service.get_container_client("mycontainer")
Part 5: Monitoring and Alerts
Monitor Service Principal Sign-ins
// Azure AD Sign-in logs for service principals
AADServicePrincipalSignInLogs
| where TimeGenerated > ago(24h)
| where ResultType != 0 // Failed sign-ins
| project TimeGenerated, ServicePrincipalName, AppId, IPAddress, Location, ResultType, ResultDescription
| order by TimeGenerated desc
Alert on Credential Expiration
# Create alert for expiring credentials
az monitor scheduled-query create \
--name "ServicePrincipalCredentialExpiry" \
--resource-group rg-security \
--scopes "/subscriptions/{subscription-id}" \
--condition "count > 0" \
--condition-query "
AuditLogs
| where TimeGenerated > ago(1d)
| where OperationName == 'Update application - Certificates and secrets management'
| where Result == 'success'
" \
--description "Alert when service principal credentials are modified" \
--evaluation-frequency "1h" \
--window-size "1h" \
--severity 2 \
--action-groups "/subscriptions/{sub-id}/resourceGroups/rg-security/providers/microsoft.insights/actionGroups/SecurityAlerts"
PowerShell Script for Expiry Report
# Generate credential expiry report
Connect-MgGraph -Scopes "Application.Read.All"
$report = Get-MgApplication -All | ForEach-Object {
$app = $_
$passwordCreds = $app.PasswordCredentials | ForEach-Object {
[PSCustomObject]@{
AppName = $app.DisplayName
AppId = $app.AppId
CredentialType = "Secret"
KeyId = $_.KeyId
EndDate = $_.EndDateTime
DaysRemaining = [math]::Round(($_.EndDateTime - (Get-Date)).TotalDays)
Status = if ($_.EndDateTime -lt (Get-Date)) { "EXPIRED" }
elseif ($_.EndDateTime -lt (Get-Date).AddDays(30)) { "EXPIRING SOON" }
else { "OK" }
}
}
$keyCreds = $app.KeyCredentials | ForEach-Object {
[PSCustomObject]@{
AppName = $app.DisplayName
AppId = $app.AppId
CredentialType = "Certificate"
KeyId = $_.KeyId
EndDate = $_.EndDateTime
DaysRemaining = [math]::Round(($_.EndDateTime - (Get-Date)).TotalDays)
Status = if ($_.EndDateTime -lt (Get-Date)) { "EXPIRED" }
elseif ($_.EndDateTime -lt (Get-Date).AddDays(30)) { "EXPIRING SOON" }
else { "OK" }
}
}
$passwordCreds + $keyCreds
}
# Export report
$report | Where-Object { $_.Status -ne "OK" } |
Export-Csv -Path "credential-expiry-report.csv" -NoTypeInformation
# Display summary
$report | Group-Object Status | Select-Object Name, Count
Terraform Configuration
terraform {
required_providers {
azuread = {
source = "hashicorp/azuread"
version = "~> 2.47"
}
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.85"
}
}
}
# Application registration
resource "azuread_application" "app" {
display_name = "my-secure-application"
}
# Service principal
resource "azuread_service_principal" "sp" {
client_id = azuread_application.app.client_id
}
# Certificate-based credential (recommended)
resource "azuread_application_certificate" "cert" {
application_id = azuread_application.app.id
type = "AsymmetricX509Cert"
value = filebase64("path/to/certificate.cer")
end_date = timeadd(timestamp(), "8760h") # 1 year
}
# Secret-based credential (with rotation)
resource "azuread_application_password" "secret" {
application_id = azuread_application.app.id
display_name = "Terraform-managed-${formatdate("YYYY-MM-DD", timestamp())}"
end_date = timeadd(timestamp(), "2160h") # 90 days
rotate_when_changed = {
rotation = time_rotating.secret_rotation.id
}
}
# Time-based rotation trigger
resource "time_rotating" "secret_rotation" {
rotation_days = 85 # Rotate 5 days before expiry
}
# Store secret in Key Vault
resource "azurerm_key_vault_secret" "sp_secret" {
name = "sp-${azuread_application.app.display_name}-secret"
value = azuread_application_password.secret.value
key_vault_id = azurerm_key_vault.main.id
expiration_date = azuread_application_password.secret.end_date
tags = {
service-principal = azuread_application.app.display_name
managed-by = "terraform"
}
}
# User-assigned managed identity (preferred over service principals)
resource "azurerm_user_assigned_identity" "app_identity" {
name = "mi-${var.app_name}"
location = var.location
resource_group_name = var.resource_group_name
}
# Assign RBAC role
resource "azurerm_role_assignment" "storage_access" {
scope = azurerm_storage_account.main.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azurerm_user_assigned_identity.app_identity.principal_id
}
Best Practices Summary
- Use managed identities whenever the Azure resource supports them
- Prefer certificates over secrets for service principal authentication
- Rotate secrets every 90 days or less
- Store credentials in Key Vault, never in code or config files
- Use separate service principals for different environments (dev, staging, prod)
- Implement least privilege - grant minimum required permissions
- Monitor sign-in logs for unusual activity
- Automate rotation to ensure consistency and reduce human error
- Maintain overlap - always have two valid credentials during rotation
Troubleshooting
Issue: Application Fails After Credential Rotation
# Verify the credential is properly configured
az ad sp credential list --id "app-id" --output table
# Check if app is using correct credential
# Review application logs for authentication errors
# Test authentication with new credential
az login --service-principal \
--username "app-id" \
--password "new-secret" \
--tenant "tenant-id"
Issue: Managed Identity Not Working
# Verify managed identity is enabled
az vm identity show --name myvm --resource-group rg-security
# Check RBAC assignments
az role assignment list \
--assignee "managed-identity-principal-id" \
--all \
--output table
# Test from within the VM
curl -H "Metadata: true" \
"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/"