Fixing the "MFA Rejected by User" Analytic Rule

Fixing the "MFA Rejected by User" Analytic Rule

Alright class.

The original "MFA Rejected by User" rule has one fatal flaw baked into its design. It uses a hard UEBA score cutoff as the only gate. If UEBARiskScore is 3 or below, the alert does not fire. Full stop. That means a user who has never rejected an MFA prompt in their life, rejecting one right now from a country they have never signed in from, on an IP flagged in threat intel, gets silently dropped if UEBA has not caught up yet. You just missed an MFA fatigue attack because a score threshold said so (saying that; hopefully you have plenty of other detections that can act as a fallback)

Not All Rejections Are Equal

The original rule treated every MFA rejection the same. One denial, one alert, same severity regardless of context. The rewrite uses a composite score across 14 signals so that a user who rejected a prompt from a known IP on a managed device scores completely differently to a user whose source IP is simultaneously spraying credentials across your entire tenant.

A confirmed fraud report adds 5 points. That is the highest single weight because when a user hits "report fraud" in their Authenticator app, they are not just saying no to a prompt. They are telling Microsoft something is actively wrong. A threat intel hit on the source IP adds 4. A password spray IP adds 4. An unknown IP adds 2. The score reflects real-world severity, not just presence or absence of an event.

Speed Matters

The original rule counts denials but does not care when they happened within the hour. Three rejections in 90 seconds and three rejections spread across 58 minutes produce the same FailureCount. They are not the same thing.

The VelocityCheck subquery runs a separate 15 minute window inside the main 1 hour lookback. Three or more denials within that tighter window sets IsBurstAttack to true and adds 3 points to the score. MFA fatigue attacks work by flooding the user with prompts in rapid succession hoping they tap approve out of frustration. A count field does not catch that. A burst window does.

Tuning This When It Gets Noisy

The CompositeScore >= 3 threshold in the query is your first dial. A score of 3 means two low-weight signals fired together, an unknown IP and no recent successful login for example. If that is generating volume in your environment, raise it to 4 or 5 before touching anything else. Do not go straight to reweighting signals until you understand what is actually driving the noise.

If FirstTimeMFADenial is the only indicator on most alerts, your environment has a lot of first-time legitimate denials. That signal is useful for detecting genuinely new attack patterns but worthless as a standalone alert. Drop its weight from 2 to 1, or remove it from the score entirely and keep it as a contextual field only.

If NoRecentSuccessfulLogin fires constantly for a specific group of users, check whether those are service accounts or shared mailboxes that never do interactive sign-ins. They will always score that signal. Either filter them out by adding | where UserType != "Guest" and cross-referencing IsServiceAccount from IdentityInfo, or drop that signal's weight to 0 for accounts where IsServiceAccount == true.

The PasswordSprayIPs threshold is SprayUniqueUsers >= 5. If your tenant is small, lower this to 3. If you are in a large enterprise with a noisy shared egress IP, you may need to raise it to 10 or add an exclusion for known corporate NAT addresses.

If BehaviorAnalytics is not populated in your workspace yet, UEBARiskScore will always be 0 and IsThreatIntelHit will always be false. Those two signals account for up to 6 points combined. Until UEBA has enough data to be meaningful, consider temporarily lowering the CompositeScore threshold by 1 to compensate for the missing signal weight.

KQL

// =====================================================================
// MFA Denial - Composite Risk Score
// =====================================================================
// Description : Detects risk-scored MFA denial events by correlating 14 weighted
//               signals including fraud reports, threat intel, password spray,
//               burst velocity, new countries, and UEBA enrichment per user.
// Type        : Detection
//
// Tables      : SigninLogs, IdentityInfo, BehaviorAnalytics
//
// Tuning      : - CompositeScore >= 3 threshold - raise to 4-5 if alert volume is high
//               - BaselineWindow (14d)
//               - VelocityWindow (15m) - tighten to 5m for faster burst detection
//               - LookbackWindow (1h) - must align with queryFrequency
//               - PasswordSprayIPs SprayUniqueUsers >= 5 - lower to 3 for small tenants
//               - FirstTimeMFADenial weight (2) - reduce or remove if signal dominates noise
//
// Known FPs   : - Service accounts / shared mailboxes trigger NoRecentSuccessfulLogin; filter by IsServiceAccount
//               - Travelling users trigger CountryMismatch until BaselineWindow catches up; extend baseline
//               - Repeat habitual decliners inflate IsPersistentTarget; deprioritise with HistoricalDenialDays
//
// Author      : Bartosz Wysocki | https://www.itprofessor.cloud
// Version     : 1.0 | 2025-06-13
// =====================================================================
let LookbackWindow = 1h;
let BaselineWindow = 30d;
let VelocityWindow = 15m;
let LegacyAuthClients = dynamic(["Exchange ActiveSync", "IMAP4", "POP3", "SMTP Auth", "Other clients", "Authenticated SMTP"]);
let KnownGoodIPs = materialize(
    SigninLogs
    | where TimeGenerated > ago(BaselineWindow)
    | where ResultType == 0
    | where isnotempty(UserPrincipalName)
    | where ClientAppUsed !in (LegacyAuthClients)
    | summarize KnownIPs = make_set(IPAddress), KnownCountries = make_set(tostring(LocationDetails.countryOrRegion)) by UserPrincipalName = tolower(UserPrincipalName));
let MFADenialBaseline = materialize(
    SigninLogs
    | where TimeGenerated > ago(BaselineWindow)
    | where ResultType == 500121
    | where isnotempty(UserPrincipalName)
    | extend additionalDetails_ = tostring(Status.additionalDetails)
    | where additionalDetails_ =~ "MFA denied; user declined the authentication" or additionalDetails_ has "fraud"
    | summarize
        HistoricalDenialCount = count(),
        HistoricalDenialDays = dcount(bin(TimeGenerated, 1d)),
        HistoricalDenialIPs = make_set(IPAddress)
        by UserPrincipalName = tolower(UserPrincipalName));
let RecentSuccessfulLogins = SigninLogs
    | where TimeGenerated > ago(7d)
    | where ResultType == 0
    | where isnotempty(UserPrincipalName)
    | where ClientAppUsed !in (LegacyAuthClients)
    | summarize
        SuccessfulIPs = make_set(IPAddress),
        LastSuccessfulLogin = max(TimeGenerated),
        SuccessfulCountries = make_set(tostring(LocationDetails.countryOrRegion)),
        ConditionalAccessPolicies = make_set(tostring(ConditionalAccessPolicies))
        by UserPrincipalName = tolower(UserPrincipalName);
let VelocityCheck = SigninLogs
    | where TimeGenerated > ago(VelocityWindow)
    | where ResultType == 500121
    | where isnotempty(UserPrincipalName)
    | extend additionalDetails_ = tostring(Status.additionalDetails)
    | where additionalDetails_ =~ "MFA denied; user declined the authentication" or additionalDetails_ has "fraud"
    | summarize VelocityCount = count() by UserPrincipalName = tolower(UserPrincipalName);
let FraudReports = SigninLogs
    | where TimeGenerated > ago(LookbackWindow)
    | where ResultType == 500121
    | where isnotempty(UserPrincipalName)
    | extend additionalDetails_ = tostring(Status.additionalDetails)
    | where additionalDetails_ has "fraud"
    | summarize FraudReportCount = count() by UserPrincipalName = tolower(UserPrincipalName);
let PasswordSprayIPs = SigninLogs
    | where TimeGenerated > ago(LookbackWindow)
    | where ResultType == 50126
    | where isnotempty(UserPrincipalName)
    | summarize SprayUniqueUsers = dcount(UserPrincipalName), SprayAttemptCount = count() by IPAddress
    | where SprayUniqueUsers >= 5
    | project FailedIPAddress = IPAddress, SprayUniqueUsers, SprayAttemptCount;
let FailedAttempts = SigninLogs
    | where TimeGenerated > ago(LookbackWindow)
    | where ResultType == 500121
    | where isnotempty(UserPrincipalName)
    | extend additionalDetails_ = tostring(Status.additionalDetails)
    | where additionalDetails_ =~ "MFA denied; user declined the authentication" or additionalDetails_ has "fraud"
    | extend UserPrincipalName = tolower(UserPrincipalName)
    | extend Country = tostring(LocationDetails.countryOrRegion)
    | extend City = tostring(LocationDetails.city)
    | summarize
        TimeGenerated = max(TimeGenerated),
        FirstDenialInWindow = min(TimeGenerated),
        FailedIPAddresses = make_set(IPAddress),
        FailedIPAddress = any(IPAddress),
        Countries = make_set(Country),
        Cities = make_set(City),
        AppsTargeted = make_set(AppDisplayName),
        ClientAppsUsed = make_set(ClientAppUsed),
        DeviceDisplayName = any(DeviceDetail.displayName),
        DeviceIsCompliant = any(DeviceDetail.isCompliant),
        DeviceIsManaged = any(DeviceDetail.isManaged),
        DeviceTrustType = any(DeviceDetail.trustType),
        DeviceOS = any(DeviceDetail.operatingSystem),
        DeviceId = any(DeviceDetail.deviceId),
        RiskLevelDuringSignIn = any(RiskLevelDuringSignIn),
        RiskLevelAggregated = any(RiskLevelAggregated),
        RiskState = any(RiskState),
        ConditionalAccessStatus = any(ConditionalAccessStatus),
        AuthenticationRequirement = any(AuthenticationRequirement),
        FailureCount = count()
        by UserPrincipalName, UserId, AADTenantId;
FailedAttempts
| extend Name = tolower(tostring(split(UserPrincipalName, '@', 0)[0]))
| extend UPNSuffix = tostring(split(UserPrincipalName, '@', 1)[0])
| join kind=leftouter RecentSuccessfulLogins on UserPrincipalName
| join kind=leftouter KnownGoodIPs on UserPrincipalName
| join kind=leftouter MFADenialBaseline on UserPrincipalName
| join kind=leftouter VelocityCheck on UserPrincipalName
| join kind=leftouter FraudReports on UserPrincipalName
| join kind=leftouter PasswordSprayIPs on FailedIPAddress
| join kind=leftouter (
    IdentityInfo
    | where TimeGenerated > ago(BaselineWindow)
    | summarize arg_max(TimeGenerated, *) by AccountUPN
    | project
        AccountUPN,
        Tags,
        JobTitle,
        Department,
        Manager,
        GroupMembership,
        AssignedRoles,
        UserType,
        IsAccountEnabled,
        IsMFARegistered,
        IsServiceAccount,
        EntityRiskScore,
        UserStateChangedOn
    | extend UserPrincipalName = tolower(AccountUPN)
) on UserPrincipalName
| join kind=leftouter (
    BehaviorAnalytics
    | where ActivityType in ("FailedLogOn", "LogOn")
    | where isnotempty(SourceIPAddress)
    | where isnotempty(UserName)
    | extend BAUserName = tolower(UserName)
    | extend ISP_ = coalesce(tostring(DevicesInsights.ISP), "Not Available")
    | extend ThreatIntelType = tostring(DevicesInsights.ThreatIntelIndicatorType)
    | extend ThreatIntelDescription = tostring(DevicesInsights.ThreatIntelIndicatorDescription)
    | summarize arg_max(TimeGenerated, *)
        by FailedIPAddress = SourceIPAddress, BAUserName
    | extend ISP_ = coalesce(tostring(DevicesInsights.ISP), "Not Available")
    | extend ThreatIntelType = tostring(DevicesInsights.ThreatIntelIndicatorType)
    | extend ThreatIntelDescription = tostring(DevicesInsights.ThreatIntelIndicatorDescription)
    | project FailedIPAddress, BAUserName, ISP_, ThreatIntelType, ThreatIntelDescription, MaxInvestigationScore = InvestigationPriority, UsersInsights, DevicesInsights, ActivityInsights
) on FailedIPAddress, $left.Name == $right.BAUserName
| extend SuccessfulIPs = coalesce(SuccessfulIPs, dynamic([]))
| extend KnownIPs = coalesce(KnownIPs, dynamic([]))
| extend KnownCountries = coalesce(KnownCountries, dynamic([]))
| extend VelocityCount = coalesce(VelocityCount, 0)
| extend FraudReportCount = coalesce(FraudReportCount, 0)
| extend HistoricalDenialCount = coalesce(HistoricalDenialCount, 0)
| extend HistoricalDenialDays = coalesce(HistoricalDenialDays, 0)
| extend UEBARiskScore = coalesce(MaxInvestigationScore, 0)
| extend SprayUniqueUsers = coalesce(SprayUniqueUsers, 0)
| extend SprayAttemptCount = coalesce(SprayAttemptCount, 0)
| extend NewCountries = set_difference(Countries, KnownCountries)
| extend CountryMismatch = array_length(NewCountries) > 0
| extend IsFailedIPKnownGood = iff(array_index_of(KnownIPs, FailedIPAddress) != -1, true, false)
| extend IsFailedIPRecentSuccess = iff(array_index_of(SuccessfulIPs, FailedIPAddress) != -1, true, false)
| extend HadRecentSuccessfulLogin = iff(isnotempty(LastSuccessfulLogin) and LastSuccessfulLogin > ago(1h), true, false)
| extend IsFirstTimeDenial = iff(HistoricalDenialCount == 0, true, false)
| extend IsPersistentTarget = iff(HistoricalDenialDays >= 3, true, false)
| extend IsBurstAttack = iff(VelocityCount >= 3, true, false)
| extend IsFraudReport = iff(FraudReportCount > 0, true, false)
| extend IsHighPrivilegeUser = iff(isnotempty(tostring(AssignedRoles)) and tostring(AssignedRoles) != "[]", true, false)
| extend IsAccountDisabled = iff(IsAccountEnabled == false, true, false)
| extend AADRiskElevated = iff(RiskLevelDuringSignIn in ("medium", "high"), true, false)
| extend IsThreatIntelHit = iff(isnotempty(ThreatIntelType), true, false)
| extend IsPasswordSprayIP = iff(SprayUniqueUsers >= 5, true, false)
| extend CompositeScore = 0
| extend CompositeScore = CompositeScore + iff(IsFraudReport, 5, 0)
| extend CompositeScore = CompositeScore + iff(IsThreatIntelHit, 4, 0)
| extend CompositeScore = CompositeScore + iff(IsPasswordSprayIP, 4, 0)
| extend CompositeScore = CompositeScore + iff(IsBurstAttack, 3, 0)
| extend CompositeScore = CompositeScore + iff(CountryMismatch, 3, 0)
| extend CompositeScore = CompositeScore + iff(IsAccountDisabled, 3, 0)
| extend CompositeScore = CompositeScore + iff(not(IsFailedIPKnownGood), 2, 0)
| extend CompositeScore = CompositeScore + iff(IsFirstTimeDenial, 2, 0)
| extend CompositeScore = CompositeScore + iff(IsHighPrivilegeUser, 2, 0)
| extend CompositeScore = CompositeScore + iff(AADRiskElevated, 2, 0)
| extend CompositeScore = CompositeScore + iff(IsPersistentTarget, 2, 0)
| extend CompositeScore = CompositeScore + iff(UEBARiskScore > 3, 2, 0)
| extend CompositeScore = CompositeScore + iff(HadRecentSuccessfulLogin == false, 1, 0)
| extend CompositeScore = CompositeScore + iff(FailureCount > 5, 1, 0)
| extend Risk_1 = iff(IsFraudReport, strcat("MFAFraudReported(", tostring(FraudReportCount), ")"), "")
| extend Risk_2 = iff(IsThreatIntelHit, strcat("ThreatIntel:", ThreatIntelType), "")
| extend Risk_3 = iff(IsPasswordSprayIP, strcat("PasswordSprayIP(", tostring(SprayUniqueUsers), "users)"), "")
| extend Risk_4 = iff(IsBurstAttack, strcat("BurstAttack(", tostring(VelocityCount), "in15min)"), "")
| extend Risk_5 = iff(CountryMismatch, strcat("NewCountry(", strcat_array(NewCountries, ","), ")"), "")
| extend Risk_6 = iff(IsAccountDisabled, "AccountDisabled", "")
| extend Risk_7 = iff(not(IsFailedIPKnownGood), "UnknownIP", "")
| extend Risk_8 = iff(IsFirstTimeDenial, "FirstTimeMFADenial", "")
| extend Risk_9 = iff(IsPersistentTarget, strcat("PersistentTarget(", tostring(HistoricalDenialDays), "days)"), "")
| extend Risk_10 = iff(IsHighPrivilegeUser, "HighPrivilegeUser", "")
| extend Risk_11 = iff(AADRiskElevated, strcat("AADRisk:", RiskLevelDuringSignIn), "")
| extend Risk_12 = iff(HadRecentSuccessfulLogin == false, "NoRecentSuccessfulLogin", "")
| extend Risk_13 = iff(FailureCount > 5, strcat("HighDenialVolume(", tostring(FailureCount), ")"), "")
| extend Risk_14 = iff(UEBARiskScore > 3, strcat("UEBAScore:", tostring(UEBARiskScore)), "")
| 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, " | "), ""),
    iff(isnotempty(Risk_8), strcat(Risk_8, " | "), ""),
    iff(isnotempty(Risk_9), strcat(Risk_9, " | "), ""),
    iff(isnotempty(Risk_10), strcat(Risk_10, " | "), ""),
    iff(isnotempty(Risk_11), strcat(Risk_11, " | "), ""),
    iff(isnotempty(Risk_12), strcat(Risk_12, " | "), ""),
    iff(isnotempty(Risk_13), strcat(Risk_13, " | "), ""),
    iff(isnotempty(Risk_14), strcat(Risk_14, " | "), "")
))
| where CompositeScore >= 3
| project
    TimeGenerated,
    FirstDenialInWindow,
    UserPrincipalName,
    Name,
    UPNSuffix,
    UserId,
    AADTenantId,
    CompositeScore,
    RiskIndicators,
    FailureCount,
    VelocityCount,
    IsBurstAttack,
    IsFraudReport,
    FraudReportCount,
    IsFirstTimeDenial,
    IsPersistentTarget,
    HistoricalDenialCount,
    HistoricalDenialDays,
    IsPasswordSprayIP,
    SprayUniqueUsers,
    SprayAttemptCount,
    FailedIPAddress,
    FailedIPAddresses,
    Countries,
    NewCountries,
    Cities,
    CountryMismatch,
    IsFailedIPKnownGood,
    IsFailedIPRecentSuccess,
    KnownCountries,
    SuccessfulCountries,
    AppsTargeted,
    ClientAppsUsed,
    ConditionalAccessStatus,
    ConditionalAccessPolicies,
    AuthenticationRequirement,
    RiskLevelDuringSignIn,
    RiskLevelAggregated,
    RiskState,
    DeviceDisplayName,
    DeviceIsCompliant,
    DeviceIsManaged,
    DeviceTrustType,
    DeviceOS,
    DeviceId,
    JobTitle,
    Department,
    Manager,
    GroupMembership,
    AssignedRoles,
    UserType,
    IsAccountEnabled,
    IsMFARegistered,
    IsHighPrivilegeUser,
    IsAccountDisabled,
    EntityRiskScore,
    UserStateChangedOn,
    Tags,
    UEBARiskScore,
    ISP_,
    ThreatIntelType,
    ThreatIntelDescription,
    UsersInsights,
    DevicesInsights,
    ActivityInsights,
    LastSuccessfulLogin,
    HadRecentSuccessfulLogin,
    SuccessfulIPs
| sort by CompositeScore desc, FailureCount desc

Ready analytic rule that you can export to Sentinel

Class dismissed

Consent Preferences