Query Details

HUNT 15 All Workload Identity Stale Inventory 180d

Query

// Hunt    : Workload Identity - All SP & MI Stale Identity Inventory (180d)
// Purpose : A single consolidated inventory of all Service Principals and Managed
//           Identities sorted by last authentication date. Categorises each identity
//           into Active / Aging / Stale / Abandoned / NeverSignedIn buckets.
//           Designed as a periodic governance sweep — run quarterly to identify
//           workload identities that have outlived their usefulness and should be
//           reviewed for disablement or deletion.
// Tables  : AADServicePrincipalSignInLogs, AADManagedIdentitySignInLogs, AuditLogs
// Period  : P180D
// Tactics : Defense Evasion, Persistence, Initial Access
// MITRE   : T1078.004, T1098.001
//==========================================================================================

let LookbackDays = 180d;

// ============================================================
// SERVICE PRINCIPAL sign-in activity
// ============================================================
let SPActivity = (AADServicePrincipalSignInLogs | invoke ExcludeAllowlistedIPs())
    | where TimeGenerated > ago(LookbackDays)
    | where ResultType == 0
    | summarize
        TotalSignins         = count(),
        LastSignin           = max(TimeGenerated),
        FirstSignin          = min(TimeGenerated),
        UniqueResources      = dcount(ResourceDisplayName),
        IPAddresses          = make_set(IPAddress, 3),
        CredentialTypes      = make_set(ServicePrincipalCredentialType, 5)
        by ServicePrincipalId, ServicePrincipalName, AppId
    | extend IdentityType = "ServicePrincipal";

// ============================================================
// MANAGED IDENTITY sign-in activity
// ============================================================
let MIActivity = (AADManagedIdentitySignInLogs | invoke ExcludeAllowlistedIPs())
    | where TimeGenerated > ago(LookbackDays)
    | where ResultType == 0
    | summarize
        TotalSignins         = count(),
        LastSignin           = max(TimeGenerated),
        FirstSignin          = min(TimeGenerated),
        UniqueResources      = dcount(ResourceDisplayName),
        IPAddresses          = make_set(IPAddress, 3),
        CredentialTypes      = dynamic([])
        by ServicePrincipalId, ServicePrincipalName, AppId
    | extend IdentityType = "ManagedIdentity";

// ============================================================
// COMBINE and add governance metadata from AuditLogs
// ============================================================
let AllIdentities = union SPActivity, MIActivity;

// Recent group/role changes for any workload identity
let RoleChanges = AuditLogs
    | where TimeGenerated > ago(90d)
    | where OperationName has_any (
        "Add app role assignment",
        "Remove app role assignment",
        "Add service principal credentials",
        "Update service principal",
        "Remove service principal credentials")
    | 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),
        LastChange      = max(TimeGenerated),
        ChangedBy       = make_set(Initiator, 3)
        by SPId;

// Owner information from AuditLogs
let OwnerInfo = AuditLogs
    | where TimeGenerated > ago(lookbackDays)    // reuse lookbackDays variable
    | where OperationName in (
        "Add owner to service principal",
        "Add owner to application")
    | extend SPId   = tostring(TargetResources[0].id)
    | extend Owner  = tostring(TargetResources[1].userPrincipalName)
    | summarize Owners = make_set(Owner, 5) by SPId;

// ============================================================
// FINAL ROLL-UP
// ============================================================
AllIdentities
| join kind=leftouter RoleChanges
    on $left.ServicePrincipalId == $right.SPId
| join kind=leftouter OwnerInfo
    on $left.ServicePrincipalId == $right.SPId
| extend
    DaysSinceLastSignin  = datetime_diff("day", now(), LastSignin),
    ActiveDays           = datetime_diff("day", LastSignin, FirstSignin)
| extend StalenessCategory = case(
    DaysSinceLastSignin < 30,    "Active (<30d)",
    DaysSinceLastSignin < 90,    "Aging (30-89d)",
    DaysSinceLastSignin < 180,   "Stale (90-179d)",
    "Abandoned (180d+)")
| extend RiskSignal = case(
    StalenessCategory in ("Stale (90-179d)", "Abandoned (180d+)")
        and isnotempty(RecentChanges),   "ActiveChangesOnStaleIdentity",
    DaysSinceLastSignin >= 90
        and TotalSignins == 0,           "NeverAuthenticated",
    StalenessCategory == "Abandoned (180d+)"
        and UniqueResources >= 3,        "AbandonedHighPrivilege",
    "")
| project
    IdentityType,
    ServicePrincipalId,
    ServicePrincipalName,
    AppId,
    StalenessCategory,
    DaysSinceLastSignin,
    LastSignin,
    FirstSignin,
    TotalSignins,
    UniqueResources,
    CredentialTypes,
    RecentChanges        = coalesce(RecentChanges, 0),
    ChangeTypes,
    LastChange,
    ChangedBy,
    Owners,
    RiskSignal
| sort by
    iif(StalenessCategory == "Abandoned (180d+)",  0,
    iif(StalenessCategory == "Stale (90-179d)",    1,
    iif(StalenessCategory == "Aging (30-89d)",     2, 3))) asc,
    iif(isnotempty(RiskSignal), 0, 1) asc,
    DaysSinceLastSignin desc

Explanation

This query is designed to help manage and review the usage of Service Principals and Managed Identities within an organization. Here's a simplified breakdown of what it does:

  1. Purpose: The query creates an inventory of Service Principals and Managed Identities, categorizing them based on their last sign-in activity. This helps identify which identities are actively used and which might be outdated or unused.

  2. Data Sources: It pulls data from logs related to Service Principal and Managed Identity sign-ins, as well as audit logs for any changes made to these identities.

  3. Time Frame: It looks back over the past 180 days to gather sign-in data.

  4. Activity Analysis:

    • Service Principals: It checks sign-in logs for Service Principals, counting total sign-ins, identifying the first and last sign-in dates, and noting unique resources accessed and IP addresses used.
    • Managed Identities: It performs a similar analysis for Managed Identities.
  5. Combining Data: The query combines the data from both Service Principals and Managed Identities and adds information about recent changes and owners from audit logs.

  6. Categorization: Each identity is categorized based on how long it's been since the last sign-in:

    • Active: Signed in within the last 30 days.
    • Aging: Last sign-in was between 30 and 89 days ago.
    • Stale: Last sign-in was between 90 and 179 days ago.
    • Abandoned: No sign-in for 180 days or more.
  7. Risk Signals: It flags identities that might be risky, such as those with recent changes despite being stale or abandoned, those that have never authenticated, or abandoned identities with access to multiple resources.

  8. Output: The final output lists each identity with details like its type, name, sign-in activity, changes, owners, and any risk signals. It sorts the list to prioritize identities that are abandoned or have risk signals.

This query is intended to be run quarterly to help organizations manage their identities and ensure that unused or potentially risky identities are reviewed and possibly disabled or deleted.

Details

David Alonso profile picture

David Alonso

Released: April 21, 2026

Tables

AADServicePrincipalSignInLogsAADManagedIdentitySignInLogsAuditLogs

Keywords

ServicePrincipalManagedIdentityAuditLogsIdentityTypeServicePrincipalIdServicePrincipalNameAppIdStalenessCategoryDaysSinceLastSigninLastSigninFirstSigninTotalSigninsUniqueResourcesCredentialTypesRecentChangesChangeTypesLastChangeChangedByOwnersRiskSignal

Operators

letinvokeagosummarizecountmaxmindcountmake_setextendunionhas_anycoalescetostringinjoinkindondatetime_diffcaseisnotemptyprojectsort byiifascdesc

Actions