Query Details
// Hunt : Workload Identity - Service Principals Not Used in 90 Days (Stale/Abandoned SPs)
// Tactics : Persistence
// MITRE : T1098.001 (Account Manipulation: Additional Cloud Credentials), T1078.004
// Purpose : A stale SP with active credentials is a standing attack surface — attackers
// target them because they have no active owners, rarely rotate credentials,
// and generate no alerts when used after a long gap. This query surfaces SPs
// whose last successful sign-in was more than 90 days ago but are still present
// in the directory. Correlates with AuditLogs to show if credentials were
// added recently (reactivation attempt), and AzureActivity to check for any
// recent management plane calls that may not appear in sign-in logs.
//==========================================================================================
// --- SPs that have signed in at any point (to know their last activity) ---
let AllSPActivity = (AADServicePrincipalSignInLogs | invoke ExcludeAllowlistedIPs())
| where TimeGenerated > ago(180d)
| where ResultType == "0"
| summarize
TotalSignins = count(),
LastSignin = max(TimeGenerated),
UniqueResources = dcount(ResourceDisplayName),
LastResources = make_set(ResourceDisplayName, 5)
by ServicePrincipalId, ServicePrincipalName, AppId;
// --- Inactive SPs: last sign-in was >90 days ago ---
let InactiveSPs = AllSPActivity
| where LastSignin < ago(90d);
// --- Check for recent credential additions to inactive SPs (reactivation signal) ---
let RecentCredChanges = AuditLogs
| where TimeGenerated > ago(90d)
| where OperationName has_any (
"Add service principal credentials",
"Update application – Certificates and secrets management",
"Update service principal")
| where Result =~ "success"
| extend SPId = tostring(TargetResources[0].id)
| extend Initiator = coalesce(
tostring(InitiatedBy.user.userPrincipalName),
tostring(InitiatedBy.app.displayName))
| summarize
RecentChanges = count(),
ChangeTypes = make_set(OperationName, 5),
Initiators = make_set(Initiator, 5),
LastChange = max(TimeGenerated)
by SPId;
// --- Check for recent AzureActivity calls from inactive SPs (headless management access) ---
// isfuzzy=true: AzureActivity may not be connected in all environments
let RecentMgmtActivity = union isfuzzy=true
AzureActivity,
(datatable(TimeGenerated:datetime, ActivityStatusValue:string, OperationNameValue:string, Caller:string)[])
| where TimeGenerated > ago(90d)
| where ActivityStatusValue =~ "Success"
| summarize
MgmtOps = count(),
LastMgmtOp = max(TimeGenerated),
Operations = make_set(OperationNameValue, 5)
by Caller;
InactiveSPs
| join kind=leftouter RecentCredChanges on $left.ServicePrincipalId == $right.SPId
| join kind=leftouter RecentMgmtActivity on $left.ServicePrincipalId == $right.Caller
| extend DaysInactive = datetime_diff("day", now(), LastSignin)
| extend HasRecentCredChange = RecentChanges > 0
| extend HasMgmtActivity = MgmtOps > 0
| extend RiskAssessment = case(
HasRecentCredChange == true and HasMgmtActivity == true,
"Critical — Stale SP Reactivated with New Creds + Mgmt Activity",
HasRecentCredChange == true,
"High — Stale SP Had Credential Change (Possible Reactivation)",
HasMgmtActivity == true,
"High — Stale SP Has Recent Management Plane Activity",
DaysInactive > 180,
"Medium — Very Long Dormancy (>180 days), Cleanup Recommended",
"Low — Stale SP, No Recent Activity or Changes")
| project
ServicePrincipalName, ServicePrincipalId, AppId,
LastSignin, DaysInactive, TotalSignins,
UniqueResources, LastResources,
HasRecentCredChange, RecentChanges, ChangeTypes, Initiators, LastChange,
HasMgmtActivity, MgmtOps, LastMgmtOp, Operations,
RiskAssessment
| order by RiskAssessment asc, DaysInactive desc
This query is designed to identify and assess the risk of service principals (SPs) in an Azure environment that have not been used for sign-in activities in over 90 days. Here's a simplified breakdown of what the query does:
Identify Active SPs: It first gathers data on all service principals that have signed in at least once in the last 180 days, capturing details like the total number of sign-ins, the last sign-in date, and the resources accessed.
Find Inactive SPs: From the above data, it filters out SPs whose last sign-in was more than 90 days ago, marking them as inactive.
Check for Recent Credential Changes: It examines audit logs to see if there have been any recent changes to the credentials of these inactive SPs in the last 90 days, which could indicate an attempt to reactivate them.
Look for Recent Management Activity: It checks for any recent management activities performed by these inactive SPs in Azure, which might not be captured in sign-in logs but could indicate unauthorized access or use.
Risk Assessment: The query then assesses the risk level of each inactive SP based on:
Output: Finally, it presents a list of these inactive SPs along with their details, such as the last sign-in date, number of sign-ins, recent changes, and risk assessment, sorted by risk level and inactivity duration.
The purpose of this query is to help identify potentially vulnerable SPs that could be exploited by attackers due to their inactivity and lack of monitoring, allowing for proactive security measures to be taken.

David Alonso
Released: April 21, 2026
Tables
Keywords
Operators