Fixing the "Microsoft Entra ID PowerShell accessing non-Entra ID resources" Analytic Rule

Fixing the "Microsoft Entra ID PowerShell accessing non-Entra ID resources" Analytic Rule

Alright class.

Today's rule is "Microsoft Entra ID PowerShell accessing non-Entra ID resources", which alerts when the old Azure AD PowerShell client obtains a token for anything that is not the directory. For once, the premise is decent. The execution is a filter, and the premise quietly became a museum piece while nobody was watching.

A Filter Wearing a Detection Costume

| where AppId =~ "1b730954-1685-4b74-9bfd-dac224a7b894"
| where TokenIssuerType =~ "AzureAD"
| where ResourceIdentity !in ("00000002-0000-0000-c000-000000000000", "00000003-0000-0000-c000-000000000000")
| where Status.errorCode == 0

Strip away the union plumbing and the entire detection is four where clauses: this client, this issuer, not the Graph APIs, success. Every matching row becomes an alert. No score, no memory of who does this routinely, no notion of which resource matters more than another. One legacy script that mints an Azure Service Management token every hour produces twenty four identical alerts a day, one hundred and sixty eight a week, and the rule is disabled by Friday. The half of the query that remains is furniture: project-reorder arranges columns nobody will read and order by sorts alerts that arrive one at a time.

The Premise Retired and Nobody Told the Rule

The AzureAD PowerShell module was deprecated on 30 March 2024, lost support on 30 March 2025, and Microsoft switched it off in the second half of 2025 along with the Azure AD Graph API it talks to. The rule description still points you at the module documentation for "capabilities and expected behavior". There is no expected behaviour any more. The product is gone.

What did not go anywhere is the client id. 1b730954-1685-4b74-9bfd-dac224a7b894 remains a pre-consented public client in every tenant: no consent prompt, no secret, and family refresh tokens that convert into access tokens for other audiences. That combination is why AADInternals, GraphRunner and every token phishing walkthrough hardcode it. Steal one refresh token through device code phishing and you can redeem it for Key Vault, for Azure Service Management, for Exchange, all while the sign in logs record the same tired client id.

So the retirement cuts both ways. The legitimate user base of this client collapsed towards zero, which makes the telemetry sharper than it was when Microsoft wrote the rule. And the abuse capability stayed exactly where it was. This is now a low volume, high signal feed that the original rule treats as an undifferentiated fire hose.

Score the Audience, Not the Event

The resource a token is issued for is the intent. A token for Key Vault is a different conversation to a token for Power BI, and the first use of a dead client by an identity is a different conversation to the four hundredth run of a known relic.

The rebuild scores every successful issuance on that basis. The definitional event, a retired directory client reaching a non-directory API, carries a floor of two points. First use of the client by an identity in fourteen days adds three, and that signal only activates when the baseline actually holds data, so an empty workspace cannot make everyone look new. A sensitive audience (Azure Service Management, Key Vault, Storage, Exchange, SharePoint) adds two. Identity Protection adds three for high risk and two for medium. A guest identity adds two, and so does an identity holding Entra directory roles, courtesy of IdentityInfo. Threshold five.

Known False Positives

Hardcoded relics. Thousands of old blog samples and internal scripts borrowed this client id to mint ARM tokens, precisely because it was pre-consented and nobody had to file an app registration. Those identities are in the fourteen day baseline, score four, and stay silent. The moment one of them holds a directory role and touches a sensitive audience it reaches six, which is a review, then either a TrustedIdentities entry or, better, a funeral for the script.

The new engineer runs the old runbook. First use plus a sensitive audience is seven and fires once. That alert is correct. Somebody resurrected a dead client in your tenant; find out who, migrate the runbook, move on.

Identity Protection geography. A medium risk flag on an established member scores four and stays silent, because a VPN exit node should not page anyone by itself. Risk stacks here; it never carries the alert alone. A high risk flag does clear the gate at five, and a high risk sign in through a retired public client has earned the attention.

The Rebuild

| where RiskScore >= FireThreshold

What fires: any first use of the client (five), first use against Key Vault or ARM (seven), an established identity when Identity Protection, a guest flag or a directory role stacks onto a sensitive audience (six or more). What stays silent, on purpose: the known relic hitting its usual API at four points, every hour, forever.

Degradation path: without P2 the risk signals fall silent; without UEBA the role and department enrichment falls silent; both losses leave the floor, first use and audience signals intact. A missing lookup never becomes a false alarm. The one dependency that decides everything is AADNonInteractiveUserSignInLogs, because token redemptions land there rather than in the interactive stream, and that gets its own line in the actions below (now you know that this table isn't as useless as a lot of folks trying to paint it)

Expected volume: in a tenant that finished its migration, a handful of alerts a month, most of them resurrection events worth a conversation.

The RiskIndicators Field

Retired directory client reached a non-directory API | First use of this client by this identity in the baseline window | Sensitive resource: Azure Key Vault

Each firing signal appends its line, so the analyst reads the whole story in one field. The combination to chase: first use, sensitive resource and Identity Protection high together. That is a phished token being spent in front of you, and it deserves the phone call before the coffee.

The Blind Spots You Should Know About

  • Sibling clients. An attacker who moves to the Azure CLI id (04b07795) or the Microsoft Office id (d3590ed6) walks past this rule. ClientAppId is a variable, but every client needs its own expected audience model; the Azure CLI reaching ARM is Tuesday, not a signal. Companion rule territory.
  • Microsoft Graph through this client is excluded by design, and directory dumping through Graph is the loudest post-phish move there is. That axis belongs to a directory reconnaissance rule, not this one.
  • A compromised identity already in the baseline, touching non-sensitive audiences, scores four. Identity Protection is the rescue there.
  • Identities quiet for more than fourteen days refire as first use. With a retired client, that is a feature.
  • Service principals sign in through AADServicePrincipalSignInLogs, a different table and a different rule.

MITRE Mappings for the Updated Rule

  • T1078 Valid Accounts and T1078.004 Cloud Accounts: the token is valid and the account is real; the use is what condemns it.
  • T1550 Use Alternate Authentication Material and T1550.001 Application Access Token: redeeming refresh tokens across audiences is the exact mechanic this telemetry records.

Rule Settings

  • Frequency: 1 hour
  • Query period: 14 days, equal to the largest lookup, the client usage baseline
  • Severity: Medium
  • Event grouping: alert per result (one row per identity, audience and address per run)
  • Incident grouping: by Account entity, 6 hour window
  • Entity mappings: Account (Name, UPNSuffix, AadUserId), IP (Address)
  • Custom details: Resource, ResourceId, ResourceTier, Events, RiskLevel, RiskState, UserAgent, SourceTable, GuestHomeDomain, Department, JobTitle, DirectoryRoles, RiskScore, RiskIndicators

KQL

// =====================================================================
// Entra ID PowerShell Client - Non-Directory Access Risk
// =====================================================================
// Description : Scores every successful token issuance to the retired
//               Entra ID (Azure AD) PowerShell public client for any
//               audience outside the directory APIs; first use, audience
//               sensitivity, Identity Protection risk, guest status and
//               directory roles rank each event so known legacy scripts
//               stay silent while resurrections and token abuse fire.
// Type        : Detection
//
// Tables      : SigninLogs, AADNonInteractiveUserSignInLogs, IdentityInfo
// Connectors  : Microsoft Entra ID (interactive and non-interactive sign
//               in streams); UEBA (Behavior Analytics) for IdentityInfo
// License     : Entra ID P1 for sign in log export; P2 adds Identity
//               Protection risk signals; UEBA required for IdentityInfo
//               (risk, role and department signals degrade to silence)
//
// Tuning      : - Set the rule query period to P14D; the 14d client usage
//                 baseline only resolves if the rule looks back that far
//               - TrustedIdentities - reviewed legacy identities only;
//                 inventory first, migrate what you find
//               - SensitiveResources - extend with audiences you guard
//               - FireThreshold - 5 by default; first use fires alone,
//                 established identities need stacked evidence
//
// Known FPs   : - Legacy scripts hardcoding this pre-consented client id
//                 for ARM or Key Vault tokens - established identities
//                 score 4 and stay silent
//               - First run of an old runbook by a new engineer - fires
//                 once as a first use, which is the intended behaviour
//               - Identity Protection medium on an established member -
//                 scores 4; risk stacks, it never carries the alert
//
// Author      : Bartosz Wysocki | https://www.itprofessor.cloud
// Version     : 1.0 | 2026-07-11
// =====================================================================
let DetectionWindow = 1h;          // rule runs hourly; only events inside the last run interval are scored
let BaselineWindow = 14d;          // client usage history depth; must equal the rule query period
let IdentityLookback = 14d;        // how far back IdentityInfo snapshots are read
let FireThreshold = 5;             // minimum RiskScore for a row to alert
let ClientAppId = "1b730954-1685-4b74-9bfd-dac224a7b894"; // retired Entra ID (Azure AD) PowerShell public client
let TrustedIdentities = dynamic([]); // UPNs of reviewed legacy identities still permitted to use this client
let DirectoryResources = dynamic(["00000002-0000-0000-c000-000000000000", "00000003-0000-0000-c000-000000000000"]); // AAD Graph and Microsoft Graph, the designed targets of this client
// Scoring weights - no signal fires alone, pairs clear the threshold, soft signals only stack
let W_NonDirectoryAccess = 2;      // floor: the retired directory client reached a non-directory API
let W_NewUserForClient = 3;        // identity has no history with this client before the detection window
let W_SensitiveResource = 2;       // token audience is a control or data plane API worth guarding
let W_HighRiskSignIn = 3;          // Identity Protection scored the sign in high risk
let W_MediumRiskSignIn = 2;        // Identity Protection scored the sign in medium risk
let W_GuestUser = 2;               // token belongs to a B2B guest identity
let W_PrivilegedUser = 2;          // identity holds Entra directory roles per IdentityInfo
// Sensitive first party audiences - extend with the APIs you guard
let SensitiveResources = datatable(ResourceIdentity: string, ResourceCategory: string) [
    "797f4846-ba00-4fd7-ba43-dac1f8f63013", "Azure Service Management",
    "cfa8b339-82a2-471a-a3c9-0fc0be7a4093", "Azure Key Vault",
    "e406a681-f3d4-42a8-90b6-c2b029497af1", "Azure Storage",
    "00000002-0000-0ff1-ce00-000000000000", "Exchange Online",
    "00000003-0000-0ff1-ce00-000000000000", "SharePoint Online"
];
// Helper - normalise both sign in tables to one schema
let ClientTokenEvents = (tableName: string) {
    table(tableName)
    | where TimeGenerated > ago(BaselineWindow)
    | where AppId =~ ClientAppId
    | where ResultType == "0"
    | project TimeGenerated, UserId = tolower(UserId), UserPrincipalName,
        UserDisplayName = column_ifexists("UserDisplayName", ""),
        UserType = column_ifexists("UserType", ""),
        IPAddress, UserAgent, ResourceIdentity = tolower(ResourceIdentity),
        ResourceDisplayName, RiskLevelDuringSignIn, RiskState, SourceTable = tableName
};
// Fourteen days of successful token issuance to the client, read twice, so materialise it once
let ClientSignIns = materialize(union isfuzzy=true
    (ClientTokenEvents("SigninLogs")),
    (ClientTokenEvents("AADNonInteractiveUserSignInLogs")));
// Lookup - identities that used the client before the detection window, so events cannot baseline themselves
let BaselineUsers = ClientSignIns
    | where TimeGenerated between (ago(BaselineWindow) .. ago(DetectionWindow))
    | distinct UserId
    | extend SeenBefore = true;
// Guard - the first use signal only fires when the baseline actually holds data
let HasHistory = toscalar(BaselineUsers | summarize count());
// Lookup - latest identity snapshot per object id, for role and department enrichment
let IdentitySnapshot = IdentityInfo
    | where TimeGenerated > ago(IdentityLookback)
    | extend AccountObjectId = tolower(AccountObjectId)
    | summarize arg_max(TimeGenerated, AssignedRoles, Department, JobTitle) by AccountObjectId
    | project AccountObjectId, AssignedRoles, Department, JobTitle;
// Main pipeline - one row per identity, audience and source address, scored on positive evidence only
ClientSignIns
| where TimeGenerated > ago(DetectionWindow)
| where ResourceIdentity !in (DirectoryResources)
| where UserPrincipalName !in~ (TrustedIdentities)
| summarize StartTime = min(TimeGenerated), EndTime = max(TimeGenerated), Events = count(),
    arg_max(TimeGenerated, UserDisplayName, UserType, UserAgent, ResourceDisplayName, RiskLevelDuringSignIn, RiskState, SourceTable)
    by UserId, UserPrincipalName, ResourceIdentity, IPAddress
| join kind=leftouter (BaselineUsers) on UserId
| join kind=leftouter (IdentitySnapshot) on $left.UserId == $right.AccountObjectId
| lookup kind=leftouter (SensitiveResources) on ResourceIdentity
| extend IsGuest = UserType =~ "Guest" or UserPrincipalName contains "#EXT#"
| extend S_NonDirectory = W_NonDirectoryAccess
| extend S_NewUser = iff(HasHistory > 0 and isnull(SeenBefore), W_NewUserForClient, 0)
| extend S_Sensitive = iff(isnotempty(ResourceCategory), W_SensitiveResource, 0)
| extend S_HighRisk = iff(RiskLevelDuringSignIn =~ "high", W_HighRiskSignIn, 0)
| extend S_MediumRisk = iff(RiskLevelDuringSignIn =~ "medium", W_MediumRiskSignIn, 0)
| extend S_Guest = iff(IsGuest, W_GuestUser, 0)
| extend S_Privileged = iff(array_length(set_difference(todynamic(AssignedRoles), dynamic([""]))) > 0, W_PrivilegedUser, 0)
| extend RiskScore = S_NonDirectory + S_NewUser + S_Sensitive + S_HighRisk + S_MediumRisk + S_Guest + S_Privileged
| where RiskScore >= FireThreshold
| extend Risk_1 = "Retired directory client reached a non-directory API"
| extend Risk_2 = iff(S_NewUser > 0, "First use of this client by this identity in the baseline window", "")
| extend Risk_3 = iff(S_Sensitive > 0, strcat("Sensitive resource: ", ResourceCategory), "")
| extend Risk_4 = iff(S_HighRisk > 0, "Identity Protection high risk sign in", "")
| extend Risk_5 = iff(S_MediumRisk > 0, "Identity Protection medium risk sign in", "")
| extend Risk_6 = iff(S_Guest > 0, "Guest identity", "")
| extend Risk_7 = iff(S_Privileged > 0, strcat("Identity holds directory roles: ", tostring(AssignedRoles)), "")
| extend RiskIndicators = trim(@"\s\|\s*$", strcat(
    iff(isnotempty(Risk_1), strcat(Risk_1, " | "), ""),
    iff(isnotempty(Risk_2), strcat(Risk_2, " | "), ""),
    iff(isnotempty(Risk_3), strcat(Risk_3, " | "), ""),
    iff(isnotempty(Risk_4), strcat(Risk_4, " | "), ""),
    iff(isnotempty(Risk_5), strcat(Risk_5, " | "), ""),
    iff(isnotempty(Risk_6), strcat(Risk_6, " | "), ""),
    iff(isnotempty(Risk_7), strcat(Risk_7, " | "), "")))
| extend GuestHomeDomain = iff(IsGuest, extract(@"_([^_]+)#EXT#", 1, UserPrincipalName), "")
| extend ResourceName = iff(isnotempty(ResourceDisplayName), ResourceDisplayName, ResourceIdentity)
| extend Name = tostring(split(UserPrincipalName, "@", 0)[0])
| extend UPNSuffix = tostring(split(UserPrincipalName, "@", 1)[0])
| project StartTime, EndTime, Events, UserPrincipalName, UserDisplayName, UserId, Name, UPNSuffix,
    IPAddress, UserAgent, ResourceName, ResourceIdentity, ResourceCategory,
    RiskLevel = RiskLevelDuringSignIn, RiskState, GuestHomeDomain, Department, JobTitle,
    DirectoryRoles = tostring(AssignedRoles), SourceTable, RiskScore, RiskIndicators
| sort by RiskScore desc, EndTime desc

You can also download this as an analytic rule and import it directly to Sentinel.

Follow my repo - GitHub

What You Should Do Next

  1. Confirm AADNonInteractiveUserSignInLogs is flowing into the workspace. Token redemptions land in the non-interactive stream, and with only interactive SigninLogs this rule watches the wrong door. This decides whether the rule works at all.
  2. Run the query for fourteen days with the score gate removed and summarise by UserPrincipalName. The output is your migration hit list and the only honest source for TrustedIdentities.
  3. Extend SensitiveResources with the audiences you actually guard; Azure DevOps (499b84ac) and the Office 365 Management API are common additions.
  4. Finish the migration off the dead module, and when the inventory comes back empty, block the client with Conditional Access and let this rule watch for whoever ignores the sign.

Class dismissed.

Consent Preferences