Skip to main content

Service Principals

Service principals come up constantly in enterprise Azure environments because most automated workloads need an identity to authenticate to Azure. The questions I deal with most often are: what is this SP doing, who created it, what does it have access to, and can we replace it with a managed identity.

Service principal vs managed identity

The short version of when I use each:

Use a managed identity when:

  • The workload runs on Azure compute (VM, App Service, Container Apps, AKS pod, Function App)
  • You don't want to manage credential rotation
  • The identity scope is limited to Azure resources

Use a service principal when:

  • The workload runs outside Azure (on-premises, another cloud, GitHub Actions, external CI/CD)
  • You need federated credentials (OIDC) without a managed identity
  • The use case requires fine-grained control over credential type and rotation

The main operational advantage of managed identities is that there are no secrets to rotate or leak. I push teams toward managed identities wherever the workload is Azure-hosted. Service principals are the right tool when the workload genuinely runs outside Azure or needs cross-tenant capabilities.

Auditing what a service principal has access to

One of the most common tasks I end up doing: an SP exists, nobody can explain what it does, and we need to understand its blast radius before decommissioning or modifying it.

Azure Resource Graph — all role assignments for an SP

AuthorizationResources
| where type == "microsoft.authorization/roleassignments"
| where properties.principalId == "<ServicePrincipalObjectId>"
| extend scope = tostring(properties.scope)
| extend roleDefinitionId = tostring(properties.roleDefinitionId)
| join kind=leftouter (
AuthorizationResources
| where type == "microsoft.authorization/roledefinitions"
| project roleDefId = tostring(id), roleName = tostring(properties.roleName)
) on $left.roleDefinitionId == $right.roleDefId
| project scope, roleName, principalId = tostring(properties.principalId)
| order by scope asc

This runs across all subscriptions you have Reader access to via Azure Resource Graph. Note: management group-scoped assignments are not returned by this query — run it separately against the management group scope if needed.

Log Analytics — sign-in and control plane activity

// What resources has this SP accessed?
AADServicePrincipalSignInLogs
| where ServicePrincipalId == "<ServicePrincipalObjectId>"
| summarize
LastSeen = max(TimeGenerated),
RequestCount = count()
by ResourceDisplayName, ResultType
| order by LastSeen desc

// What control plane operations has it performed?
AzureActivity
| where Caller == "<ServicePrincipalObjectId>"
| project TimeGenerated, OperationNameValue, ActivityStatusValue,
Caller, CallerIpAddress, ResourceProviderValue, ResourceId
| order by TimeGenerated desc

The AzureActivity query shows every ARM operation the SP has performed — resource creates, deletes, policy assignments, role assignment changes. This is what I use to understand whether an SP is active and what teams depend on it.

Bulk role assignment analysis with Python

When I need to inventory SP role assignments across the entire tenant (not just one subscription), I use the Azure SDK:

from azure.identity import DefaultAzureCredential
from azure.mgmt.authorization import AuthorizationManagementClient
from azure.mgmt.resource import SubscriptionClient

credential = DefaultAzureCredential()

def get_sp_role_assignments(sp_object_id: str) -> list[dict]:
"""
Returns all role assignments for a service principal across all accessible subscriptions.
Note: does NOT include management group-scoped assignments.
"""
sub_client = SubscriptionClient(credential)
results = []

for sub in sub_client.subscriptions.list():
auth_client = AuthorizationManagementClient(credential, sub.subscription_id)
assignments = auth_client.role_assignments.list(
filter=f"principalId eq '{sp_object_id}'"
)
for assignment in assignments:
role_def = auth_client.role_definitions.get_by_id(
assignment.role_definition_id
)
results.append({
"subscription": sub.display_name,
"subscription_id": sub.subscription_id,
"scope": assignment.scope,
"role": role_def.role_name,
"assignment_id": assignment.id
})

return results

# Usage
assignments = get_sp_role_assignments("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx")
for a in assignments:
print(f"{a['subscription']} | {a['role']} | {a['scope']}")

DefaultAzureCredential chains through az login, environment variables, managed identity, and other mechanisms — it works locally and in CI without code changes.

Federated identity credentials for GitHub Actions

Federated credentials (OIDC) are the right way to authenticate GitHub Actions to Azure. No secrets, no rotation, just token exchange at runtime.

The trust relationship on the SP:

{
"name": "github-actions-prod",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:myorg/myrepo:environment:production",
"audiences": ["api://AzureADTokenExchange"]
}

The subject field controls what gets trusted. Common patterns:

  • repo:org/repo:ref:refs/heads/main — only the main branch
  • repo:org/repo:environment:production — only the production environment
  • repo:org/repo:pull_request — PR workflows only

In Terraform:

resource "azuread_application_federated_identity_credential" "github" {
application_object_id = azuread_application.app.object_id
display_name = "GitHub Actions"
audiences = ["api://AzureADTokenExchange"]
issuer = "https://token.actions.githubusercontent.com"
subject = "repo:${var.github_org}/${var.github_repo}:environment:production"
}

Locking down federated credential issuers with Azure Policy

One thing I've enforced in environments with strict identity controls: a policy that restricts which issuers are permitted on federated credentials. Without it, a user could create a federated credential that trusts any OIDC provider, which creates impersonation risk.

{
"if": {
"allOf": [
{
"field": "type",
"equals": "Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials"
},
{
"field": "Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials/issuer",
"notIn": [
"https://token.actions.githubusercontent.com",
"https://vstoken.dev.azure.com"
]
}
]
},
"then": {
"effect": "Deny"
}
}

This policy denies creation of federated credentials that use any issuer outside the approved list. Adjust the notIn list to match your authorised identity providers.

Things I've gotten wrong

Querying role assignments at subscription scope misses management group assignments

Subscription-scoped API calls only return assignments at or below subscription scope. If an SP has an assignment at the management group level, it won't appear. I've done decommissioning work confident an SP had no permissions, only to find it had a Contributor assignment at the root management group. Always query management group scope separately.

Certificate-based credentials silently expiring

SPs using certificate authentication fail silently after the certificate expires. The error from most applications is a generic authentication failure with no mention of certificates. I track SP certificate expiry in Azure Monitor by querying AuditLogs for credential changes, and set alerts for credentials expiring within 30 days.

Conditional access does not apply to service principals

Conditional access policies cannot target service principals. If you're trying to restrict where an SP can authenticate from (specific IP ranges, specific devices), the right controls are RBAC scoping, Azure Policy, and network-level restrictions — not conditional access.