Fixing the "Rare Application Consent" Analytic Rule

Fixing the "Rare Application Consent" Analytic Rule

Alright class.

Back in the Entra audit log for this one. The rule is called "Rare application consent" and the premise comes straight from consent phishing: an attacker registers an app, sends a link, and a user grants it permissions with one click. The description even names PwnAuth, the FireEye tool that automated the whole thing. Consent abuse is real and deserves a detection. This rule is not it, and the reason sits in one summarize.

Wrong Question

Rarity gate asks the wrong question, because consent is rare by construction. Nearly every consent in your tenant is a first for that user and that app. Firing on novelty means firing on Tuesdays.

Think instead about who abuses consent and how, because there are three of them.

The phishing crew. They register an app, mail out the grant link, and harvest mailboxes from whoever clicks. The tells: mail and file scopes with offline_access so the access survives on refresh tokens, several colleagues consenting to the same app in a short window, and consents riding sign-in sessions Entra already scored risky.

The attacker who is already in. Holding an admin's session, they consent their app tenant-wide, every mailbox in one click. The tell is the pairing: sensitive scopes and an AllPrincipals grant.

The insider. They do not need phishing. They register an app in your own tenant, grant it Files.Read.All against their own account, and walk the data out through an API nobody watches. The tell is in the audit log: the service principal was created by an in-tenant user, and often the creator and the consenter are the same person.

None of these stories needs a rarity score. Each is visible from what was granted, to what, by whom, and from where, all in the consent event or one lookup away.

Most Consents Are Nobody's Problem

The volume fix comes before the scoring. A consent that grants openid, profile, User.Read and offline_access can do almost nothing to you, and it is the overwhelming majority of consent traffic. So the rebuild gates on the granted permissions: if the scopes touch nothing sensitive, no mail, files, sites or directory, the event never reaches scoring. On a normal day this rule should produce zero rows, and that is the design goal, not a side effect. The SOC does not need a new alert category; it needs to hear about consent only when there is something to do.

The Rebuild

Two gates, in order:

| where PermBlob has_any (RiskyScopes)
...
| where RiskScore >= FireThreshold

Degradation is graceful. The scope gate, tenant-wide, wave, self-registered and novelty signals read from AuditLogs alone. Sign-in risk needs P2, and consenter roles need UEBA; without them those two signals stay silent and the rule still runs. Signals fire on positive evidence only, so a thin lookup never invents an alert. The prior-consent history excludes the current window so events cannot vouch for themselves, and the service principal lookup includes it, because an attacker's app lands seconds before the first consent to it.

What the Analyst Sees

Every row carries its own triage. Who consented: roles, department, user type. From where: the session's country and how many risky sign-ins that user and IP produced lately. To what: the app, who created its service principal and when. What was handed over: the granted scopes, verbatim. An analyst reads the incident, not the query. If SpCreator matches the consenter and GrantedScopes says Files.Read.All, that is an interview, not an investigation.

The RiskIndicators Field

Same accumulating string as the rest of the series, mapped as a custom detail:

RiskyConsentSession | PrivilegedConsenter | TenantWideGrant | ConsentWave | SelfRegisteredApp | NewToTenant | OffHoursActivity

Two combinations to chase. RiskyConsentSession with ConsentWave is a phishing campaign confirmed twice over. SelfRegisteredApp with PrivilegedConsenter is an insider with admin rights wiring up their own data pipe, and it goes to the top of the queue.

The Blind Spots You Should Know About

The quiet single victim; clean session, no privilege, one consent. Posture and the weekly sub-threshold hunt cover what the alert cannot.

There is an easy fix for that which you should have used years ago at this point (if you don't, it's time to rethink your security approach)

Permissions granted straight through Graph, the Add delegated permission grant and Add app role assignment operations, never raise a consent prompt and never raise this operation. An attacker who already holds a privileged token bypasses this rule entirely; that path needs its own detection on the grant operations.

Publisher verification is not in the audit data, so it cannot be a signal here; it is the first thing to check in Entra during triage.

MITRE Mappings for the Updated Rule

Tactic: Credential Access, with Persistence for the durable grant.

T1528 Steal Application Access Token. The illicit consent grant is this technique end to end: the click hands the attacker OAuth tokens for the granted scopes, and offline_access keeps them fresh.

Rule Settings

Run every 60 minutes, and set the query period to 14 days so the 14 day lookups resolve. Medium severity, with RiskScore and the indicators separating campaigns, insiders and governance events. Alert per result, since the query collapses to one row per consenter and app. Group by the CloudApplication entity over 8 hours, not by Account, because a phishing wave is many victims and one app, and grouping by the app folds the whole campaign into a single incident.

Entity mapping:

Name to Account (Name), UPNSuffix to Account (UPNSuffix)
IpAddress to IP (Address)
AppName to CloudApplication (Name)

Custom details: RiskScore, RiskIndicators, AppName, AppId, GrantedScopes, ConsenterRoles, Department, UserType, Country, HighRiskSignIns, TenantWide, NewToTenant, WaveCount, SpCreator, UserAgent, InitiatorType.

KQL

// =====================================================================
// Application Consent Risk - Entra ID
// =====================================================================
// Description : Alerts on application consents that grant sensitive data access and carry a
//               reason for suspicion: a risky sign-in session behind the consent, a privileged
//               consenter, a tenant-wide grant, a consent wave across several people, or an app
//               registered by an in-tenant user (the insider pattern). Consents to low-risk
//               scopes never reach scoring, so the everyday openid-profile majority produces
//               nothing. Every row is enriched with the consenter's roles and department, the
//               session's country and risk, and the app's creator and age, so triage happens
//               from the incident.
// Type        : Detection
//
// Tables      : AuditLogs, SigninLogs, IdentityInfo
// Connectors  : Microsoft Entra ID (AuditLogs, SignInLogs), Microsoft Sentinel UEBA (IdentityInfo)
// License     : Microsoft Sentinel; Microsoft Entra ID P1 (audit and sign-in logs),
//               P2 recommended (sign-in risk and UEBA; without them those two signals stay silent)
//
// Tuning      : - Set the rule query period to P14D; the 14d lookups only resolve if the rule looks back that far
//               - RiskyScopes - the delegated and application permissions that make a consent worth scoring at all
//               - SensitiveRoles - the roles that make a consenter privileged
//               - WaveThreshold - distinct consenters to one app in the window that count as a wave
//               - SignInLookback - how far behind the consent to look for the session's sign-in risk
//               - BusinessStart / BusinessEnd - local working hours; TimeGenerated is UTC, shift to your tenant
//               - Widen DetectionWindow past the run frequency if slow waves split across runs
//
// Known FPs   : - An admin doing planned tenant-wide consent of a mail or file integrated app fires
//                 once; that grant hands the app every mailbox, one look is the correct price
//               - A developer registering and testing their own app with sensitive scopes fires;
//                 confirm the app and close, or exclude the dev tenant pattern
//               - A single unprivileged user consenting to a risky-scope app from a clean session
//                 stays silent by design; see the blind spots in the rule description
//
// Author      : Bartosz Wysocki | https://www.itprofessor.cloud
// Version     : 2.0 | 2026-07-03
// =====================================================================
let DetectionWindow = 1h;
let ConsentHistoryLookback = 14d;  // prior consent per app, for the NewToTenant context flag
let SpCreationLookback = 14d;      // service principal creations, for app age and creator
let IdentityLookback = 14d;        // full UEBA sync cycle, for consenter roles and department
let SignInLookback = 1d;           // how far behind the consent to look for session risk
let WaveThreshold = 3;             // distinct consenters to one app in the window
let BusinessStart = 7;
let BusinessEnd = 19;
let FireThreshold = 3;
// Scoring weights - any single strong reason fires; context signals only stack
let W_RiskySession = 3;            // the consent rode a sign-in session Entra scored medium or high
let W_PrivConsenter = 3;           // the consenter holds a sensitive directory role
let W_TenantWide = 3;              // admin consent or an AllPrincipals grant of the risky scopes
let W_Wave = 3;                    // several distinct people consented to the same app in the window
let W_SelfRegistered = 2;          // the service principal was registered by an in-tenant user
let W_NewToTenant = 1;             // first consent to the app, or its service principal is recent; context only
let W_OffHours = 1;                // activity outside working hours
let RiskyScopes = dynamic([
"Mail.Read", "Mail.ReadWrite", "Mail.Send", "MailboxSettings.ReadWrite",
"Files.Read.All", "Files.ReadWrite.All", "Sites.Read.All", "Sites.ReadWrite.All",
"Contacts.Read", "Notes.Read.All", "User.Read.All", "Directory.Read.All",
"Directory.AccessAsUser.All", "EWS.AccessAsUser.All", "full_access_as_app"
]);
let SensitiveRoles = dynamic([
"Global Administrator", "Privileged Role Administrator", "Privileged Authentication Administrator",
"Security Administrator", "User Administrator", "Application Administrator",
"Cloud Application Administrator", "Hybrid Identity Administrator",
"Exchange Administrator", "SharePoint Administrator", "Intune Administrator"
]);
// Consent events in the window that grant sensitive scopes; the low-risk majority stops here.
// Referenced twice below, so materialised once.
let ConsentEvents = materialize(
    AuditLogs
    | where TimeGenerated > ago(DetectionWindow)
    | where OperationName =~ "Consent to application"
    | where Result =~ "success"
    | extend InitiatorUPN = tolower(tostring(InitiatedBy.user.userPrincipalName))
    | extend InitiatorApp = tostring(InitiatedBy.app.displayName)
    | extend Initiator = iff(isnotempty(InitiatorUPN), InitiatorUPN, InitiatorApp)
    | extend InitiatorType = iff(isnotempty(InitiatorUPN), "User", "App")
    | extend IpAddress = iff(isnotempty(tostring(InitiatedBy.user.ipAddress)), tostring(InitiatedBy.user.ipAddress), tostring(InitiatedBy.app.ipAddress))
    | where isnotempty(Initiator)
    | mv-apply tr = TargetResources on (
        where tostring(tr.type) =~ "ServicePrincipal"
        | project AppName = tostring(tr.displayName), AppId = tostring(tr.id), PermBlob = tostring(tr.modifiedProperties)
      )
    | where isnotempty(AppId)
    | where PermBlob has_any (RiskyScopes)
    // User-Agent pulled with a null-safe extract; an mv-apply on the key drops consents that lack one
    | extend UserAgent = extract(@'"key":"User-Agent","value":"([^"]*)"', 1, tostring(AdditionalDetails))
    | extend AdminConsentVal = extract(@'IsAdminConsent[^}]*newValue"?:"?\\?"?(True|False)', 1, PermBlob)
    | extend TenantWideEvent = AdminConsentVal =~ "True" or PermBlob has "AllPrincipals"
    | extend GrantedScopes = trim(" ", extract(@"Scope:\s*([^,\]]+)", 1, PermBlob))
    | extend HourOfDay = datetime_part("Hour", TimeGenerated)
    | extend OffHoursEvent = HourOfDay < BusinessStart or HourOfDay >= BusinessEnd or dayofweek(TimeGenerated) in (0d, 6d)
);
// Apps the tenant has consented to before, excluding the current window so events cannot self-baseline
let PriorConsentApps = AuditLogs
    | where TimeGenerated between (ago(ConsentHistoryLookback) .. ago(DetectionWindow))
    | where OperationName =~ "Consent to application"
    | where Result =~ "success"
    | mv-apply tr = TargetResources on (
        where tostring(tr.type) =~ "ServicePrincipal"
        | project PriorAppId = tostring(tr.id)
      )
    | where isnotempty(PriorAppId)
    | distinct PriorAppId;
// Service principal creations: age plus creator. A user as the initiator means an in-tenant
// registration; the consent flow instantiating a foreign app is service-initiated instead.
let NewServicePrincipals = AuditLogs
    | where TimeGenerated > ago(SpCreationLookback)
    | where OperationName =~ "Add service principal"
    | where Result =~ "success"
    | extend SpCreator0 = tolower(tostring(InitiatedBy.user.userPrincipalName))
    | mv-apply tr = TargetResources on (
        where tostring(tr.type) =~ "ServicePrincipal"
        | project SpId = tostring(tr.id)
      )
    | where isnotempty(SpId)
    | summarize SpCreatedTime = min(TimeGenerated), SpCreator = take_any(SpCreator0) by SpId;
// Consenter identity: roles, department, type
let IdentityContext = IdentityInfo
    | where TimeGenerated > ago(IdentityLookback)
    | summarize arg_max(TimeGenerated, AssignedRoles, Department, JobTitle, UserType) by AccountUPN = tolower(AccountUPN);
// Sign-in risk and location for the consenting user and IP
let SignInContext = SigninLogs
    | where TimeGenerated > ago(SignInLookback)
    | project SignInUPN = tolower(UserPrincipalName), IPAddress,
              RiskLevel = tostring(RiskLevelDuringSignIn),
              Country = tostring(LocationDetails.countryOrRegion)
    | summarize HighRiskSignIns = countif(RiskLevel in ("high", "medium")), Country = take_any(Country)
      by SignInUPN, IPAddress;
// Distinct consenters per app in the window, for the wave signal
let WindowAppCounts = ConsentEvents
    | summarize WaveCount = dcount(Initiator) by AppId;
// One row per consenter and app, scored
ConsentEvents
| summarize
    StartTime = min(TimeGenerated),
    EndTime = max(TimeGenerated),
    ConsentCount = count(),
    TenantWideCnt = countif(TenantWideEvent),
    OffHoursCount = countif(OffHoursEvent),
    UserAgent = take_any(UserAgent),
    GrantedScopes = take_any(GrantedScopes)
  by Initiator, InitiatorType, AppName, AppId, IpAddress
| join kind=leftouter WindowAppCounts on AppId
| join kind=leftouter (PriorConsentApps) on $left.AppId == $right.PriorAppId
| join kind=leftouter NewServicePrincipals on $left.AppId == $right.SpId
| join kind=leftouter IdentityContext on $left.Initiator == $right.AccountUPN
| join kind=leftouter SignInContext on $left.Initiator == $right.SignInUPN, $left.IpAddress == $right.IPAddress
| extend TenantWide = TenantWideCnt > 0
| extend NewToTenant = isempty(PriorAppId) or isnotempty(SpCreatedTime)
| extend SelfRegistered = isnotempty(SpCreator)
| extend ConsenterRoles = tostring(AssignedRoles)
| extend sRiskySession = HighRiskSignIns > 0
| extend sPrivConsenter = ConsenterRoles has_any (SensitiveRoles)
| extend sTenantWide = TenantWide
| extend sWave = WaveCount >= WaveThreshold
| extend sSelfRegistered = SelfRegistered
| extend sNewToTenant = NewToTenant
| extend sOffHours = OffHoursCount > 0
| extend RiskScore =
      iff(sRiskySession, W_RiskySession, 0)
    + iff(sPrivConsenter, W_PrivConsenter, 0)
    + iff(sTenantWide, W_TenantWide, 0)
    + iff(sWave, W_Wave, 0)
    + iff(sSelfRegistered, W_SelfRegistered, 0)
    + iff(sNewToTenant, W_NewToTenant, 0)
    + iff(sOffHours, W_OffHours, 0)
| where RiskScore >= FireThreshold
| extend Name = tostring(split(Initiator, "@", 0)[0]), UPNSuffix = tostring(split(Initiator, "@", 1)[0])
| extend Risk_1 = iff(sRiskySession, "RiskyConsentSession", "")
| extend Risk_2 = iff(sPrivConsenter, "PrivilegedConsenter", "")
| extend Risk_3 = iff(sTenantWide, "TenantWideGrant", "")
| extend Risk_4 = iff(sWave, "ConsentWave", "")
| extend Risk_5 = iff(sSelfRegistered, "SelfRegisteredApp", "")
| extend Risk_6 = iff(sNewToTenant, "NewToTenant", "")
| extend Risk_7 = iff(sOffHours, "OffHoursActivity", "")
| 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, " | "), "")
))
| project
    StartTime, EndTime, RiskScore, RiskIndicators,
    Initiator, Name, UPNSuffix, InitiatorType, IpAddress, Country, UserAgent,
    AppName, AppId, GrantedScopes, ConsentCount,
    TenantWide, NewToTenant, WaveCount, SpCreator, SpCreatedTime,
    ConsenterRoles, Department, JobTitle, UserType,
    HighRiskSignIns, OffHoursCount
| 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

Fix the posture first, because it beats any detection. Restrict user consent to verified publishers and low-risk permissions in Entra, and turn on the admin consent workflow. This rule then watches the paths that remain: admins, insiders, and whatever your policy still allows.

Run both queries over the last day and compare. The original returns roughly every consent; this one should return close to nothing, and each returned row should carry its next action in its own columns. If something benign fires, the reason is in RiskIndicators, and the tuning knob is named after it.

Review the two lists against your tenant. RiskyScopes decides what is worth scoring, SensitiveRoles decides who counts as privileged, and both are your call, not mine.

Build the companion on the grant operations. Add delegated permission grant and Add app role assignment are how permissions arrive without a consent prompt, and that path deserves its own rule rather than a comment.

Class dismissed.

Consent Preferences