Service principals do not use interactive MFA. Long-lived secrets and orphaned apps create quiet persistence paths.
Preface
Non-human identities are powerful programmatic gateways into Azure and directory APIs. Their credentials need a strict lifecycle.
In most tenants, workload identities outnumber people. Every integration, backup tool, monitoring agent, provisioning script and SaaS connector tends to get an app registration, a service principal and a client secret. Each one is effectively an account that authenticates with a password, cannot be prompted for MFA, and often holds application permissions that apply to the whole tenant rather than to one user's data.
Problem
Credential drift leaves abandoned service principals with high privileges and multi-year secrets. Compromises rarely trigger user-focused alerts because the activity is non-interactive.
The typical pattern is familiar: a project creates an app with Mail.Read or Directory.ReadWrite.All, a consultant generates a two-year secret, the secret is pasted into a config file or a pipeline variable, and the project ends. The secret keeps working. Nobody owns the app, nobody reviews its permissions, and the sign-in logs that most teams watch only cover users.
How the Abuse Works in Practice
Attackers rarely need to break anything to use a service principal. The common paths are:
- Harvested secrets: client secrets and certificate private keys found in source repositories, build logs, configuration files, shared drives or compromised developer workstations.
- Credential addition: an attacker who already holds a privileged role, or is an owner of an application, adds a new secret or certificate to an existing app that already has the permissions they want. The legitimate credential keeps working, so nothing breaks and nobody notices.
- Owner abuse: application owners can manage credentials on the apps they own. Regular users listed as owners of highly privileged apps are a privilege escalation path that rarely appears in role reviews.
- Permission creep: an app that started with read access is later granted write or tenant-wide permissions "temporarily" and never reduced.
Once authenticated, the service principal uses the client credentials flow. There is no user, no device, no MFA and, in many tenants, no Conditional Access applied. The activity lands in the service principal sign-in log, which is often not collected at all.
Solution
Align with CIS Controls v8 Control 5 (Account Management) and Control 6 (Access Control Management), applied to non-human accounts:
- Limit secret lifetimes to a maximum of about 3 months.
- Run continuous access reviews of service principal permissions.
- Alert on credential additions to existing application objects.
Design Guidance
Prefer credentials you do not have to store
The strongest option is no secret at all. Workloads running in Azure should use managed identities. Workloads in GitHub Actions, Azure DevOps, Kubernetes or another cloud should use workload identity federation. Where a credential is unavoidable, use a certificate whose private key lives in a key vault or HSM rather than a client secret that is copied as plain text.
Enforce lifetimes with policy, not convention
Entra application management policies can restrict password credentials, block new secrets entirely, and cap credential lifetimes at the tenant level or per application. Some of these restrictions have licensing requirements, so check current Microsoft documentation for your tenant. A policy that rejects a 24-month secret at creation time is far more reliable than a wiki page that asks developers to choose 90 days.
Give every app an accountable owner
Record a business owner and a technical owner for every app registration, and keep the list of Entra "owners" on the object itself small. Ownership of privileged apps should be restricted to the same people who could hold an equivalent admin role.
Apply least privilege and Conditional Access
Replace tenant-wide application permissions with scoped alternatives where they exist, such as Exchange Online application access policies or RBAC for Applications, and resource-specific consent. Where licensing allows, Conditional Access for workload identities can restrict single-tenant service principals to known IP ranges.
Implementation Steps
- Inventory: export every app registration and service principal with its credentials, expiry dates, owners and granted permissions.
- Classify: mark apps holding high-impact permissions (directory write, mail, files, role management) as tier 0 or tier 1.
- Remove the dead: disable service principals with no sign-ins over an agreed period, wait, then delete. Remove expired credentials so they cannot be confused with live ones.
- Rotate: replace long-lived secrets, starting with the most privileged apps, and move them to managed identity, federation or certificates where possible.
- Enforce: enable application management policies to cap lifetimes and block new secrets on apps that should not have them.
- Monitor: collect service principal sign-ins and audit events, and alert on new credentials and permission grants.
PowerShell: Find Long-Lived Secrets
Connect-MgGraph -Scopes "Application.Read.All"
$apps = Get-MgApplication -All
foreach ($app in $apps) {
foreach ($password in $app.PasswordCredentials) {
if ($password.EndDateTime -gt (Get-Date).AddYears(1)) {
[PSCustomObject]@{
AppName = $app.DisplayName
AppId = $app.AppId
SecretId = $password.KeyId
Expiration = $password.EndDateTime
}
}
}
}
Run the same check against KeyCredentials for certificates, and against service principal objects as well as application objects. Credentials can be added directly to a service principal, where they do not appear in the app registration blade.
Monitoring and Detection
Send AuditLogs and AADServicePrincipalSignInLogs to Microsoft Sentinel or your SIEM. Two starting detections:
// New secrets or certificates on applications and service principals
AuditLogs
| where OperationName in ("Add service principal credentials",
"Update application – Certificates and secrets management")
| extend Actor = coalesce(tostring(InitiatedBy.user.userPrincipalName),
tostring(InitiatedBy.app.displayName))
| extend Target = tostring(TargetResources[0].displayName)
| project TimeGenerated, OperationName, Actor, Target, Result
// Service principal sign-ins from IP addresses not seen in the previous 14 days
let baseline = AADServicePrincipalSignInLogs
| where TimeGenerated between (ago(15d) .. ago(1d))
| distinct ServicePrincipalId, IPAddress;
AADServicePrincipalSignInLogs
| where TimeGenerated > ago(1d) and ResultType == "0"
| join kind=leftanti baseline on ServicePrincipalId, IPAddress
| summarize SignIns = count() by ServicePrincipalName, ServicePrincipalId, IPAddress
Treat any credential added outside a change window, or by an account that is not a known app owner, as an incident until proven otherwise.
Common Mistakes
- Checking app registrations only and missing credentials attached directly to service principals.
- Rotating secrets without removing the old ones, leaving two valid credentials instead of one.
- Ignoring application owners during privileged access reviews.
- Not collecting service principal sign-in logs, so there is nothing to investigate after a leak.
- Deleting first, asking later: remove unused apps in stages so an undocumented dependency fails safely and visibly.
Where to Start
Run the inventory script, sort by permission impact and expiry date, and fix the top ten. Then turn on collection of service principal sign-ins and the credential-addition alert. Those two steps close the most common persistence path and give you the evidence you need for the longer migration to managed identities and federation.