Banner

Detecting Suspicious MFA Behavior in Microsoft Sentinel

Using a 30-Day IP and Auth Method Baseline to Surface MFA Fatigue Attacks

Category Identity Security · Microsoft Sentinel · KQL

As most customer have not embraced Phishing resistant MFA, you still see a lot of MFA abuse in one form or another. MFA denial events in your sign-in logs are noisy. Users mistype codes, forget to check their phone, or decline by accident. A single denied prompt tells you almost nothing on its own. But a denial from an IP the user has never signed in from before, using an authentication method they have never used, is much harder to explain away.

This post walks through a Microsoft Sentinel analytic rule that combines two signals to cut through that noise: a per-user IP and subnet baseline built from 30 days of successful MFA sign-ins, and a per-user authentication method baseline that tracks what MFA method each person normally uses.

Goal: detect MFA denials that are location-anomalous, method-anomalous, or both, separating active credential attack patterns from the everyday background noise of MFA errors.

The attack this rule targets

Once an attacker has a valid username and password, they still need to get past MFA. The most direct approach is to trigger authentication prompts repeatedly until the user approves one. Microsoft Authenticator sends a push notification each time a sign-in attempt is made. An attacker can send dozens of these in a few minutes. Some users eventually tap "Approve" just to make them stop.

This technique was central to the Uber breach in September 2022. An attacker obtained a contractor's credentials and then sent repeated MFA push notifications to the user's phone. When the user continued to decline, the attacker contacted them directly on WhatsApp, claiming to be Uber IT support, and asked them to accept the next prompt. The user did. That combination of credential theft and repeated prompting resulted in access to Uber's internal tooling and infrastructure.

A second pattern this rule catches is method switching. If an attacker has credentials but cannot trigger push notifications on the user's registered device, they may attempt to force a fallback to SMS or a phone call. A user who has authenticated exclusively with Microsoft Authenticator push for 12 months suddenly receiving an SMS code request from an unknown IP is a concrete signal worth investigating.

ResultType 500121 in Entra ID sign-in logs means the MFA step was explicitly denied. This is distinct from session timeouts or expired codes. It means the authentication request reached the user or device and was rejected. That distinction matters: it confirms the attacker holds valid credentials and an active MFA prompt was sent and refused.


Detection overview

The analytic rule is built around three components:

SigninLogs ResultType 500121 materialize() extractSubnet() arg_max()

The KQL analytic rule

The following query is intended to run as an analytic rule in Microsoft Sentinel, using the SigninLogs table from the Microsoft Entra ID connector.


let lookbackShort = 1d;
let lookbackLong = 30d;
let extractSubnet = (ip: string) {
    iff(
        ip contains ":",
        strcat_array(array_slice(split(ip, ':'), 0, 4), ':'),
        strcat_array(array_slice(split(ip, '.'), 0, 3), '.')
    )
};
let FilteredBaseline = materialize(
    SigninLogs
    | where TimeGenerated between (ago(lookbackLong) .. ago(lookbackShort))
    | where ResultType == 0
    | where AuthenticationRequirement =~ "multiFactorAuthentication"
);
let KnownIPs = FilteredBaseline
| extend IPSubnet = extractSubnet(IPAddress)
| summarize KnownIPAddresses = make_set(IPAddress, 500), KnownSubnets = make_set(IPSubnet, 500) by UserPrincipalName;
let BaselineAuthMethod = FilteredBaseline
| mv-expand todynamic(AuthenticationDetails) to typeof(dynamic)
| extend authenticationMethod = tostring(AuthenticationDetails.authenticationMethod)
| where isnotempty(authenticationMethod)
| where authenticationMethod !in ("Password", "Previously satisfied")
| summarize MethodCount = count() by UserPrincipalName, authenticationMethod
| summarize arg_max(MethodCount, authenticationMethod) by UserPrincipalName
| project UserPrincipalName, UsualAuthMethod = authenticationMethod;
SigninLogs
| where TimeGenerated > ago(1h)
| where ResultType == 500121
| where AuthenticationRequirement =~ "multiFactorAuthentication"
| mv-expand todynamic(AuthenticationDetails) to typeof(dynamic)
| extend
    authenticationMethod = tostring(AuthenticationDetails.authenticationMethod),
    authenticationStepResultDetail = tostring(AuthenticationDetails.authenticationStepResultDetail)
| where isnotempty(authenticationMethod)
| where authenticationMethod !in ("Password", "Previously satisfied")
| where authenticationStepResultDetail in (
    "MFA denied; invalid verification code entered too many times",
    "MFA denied; user declined the authentication",
    "MFA denied; user hung up the phone call without succeeding the authentication",
    "MFA denied; user is blocked",
    "MFA denied"
  )
| summarize arg_max(TimeGenerated, IPAddress, Location, authenticationMethod, authenticationStepResultDetail, DeviceDetail) by UserPrincipalName
| extend
    UserName = tostring(split(UserPrincipalName, '@')[0]),
    UPNSuffix = tostring(split(UserPrincipalName, '@')[1]),
    IPSubnet = extractSubnet(IPAddress)
| join kind=leftouter KnownIPs on UserPrincipalName
| extend IsKnownIP = set_has_element(KnownIPAddresses, IPAddress) or set_has_element(KnownSubnets, IPSubnet)
| join kind=leftouter BaselineAuthMethod on UserPrincipalName
| extend IsUnusualAuthMethod = isnotempty(UsualAuthMethod) and authenticationMethod != UsualAuthMethod
| extend AuthMethodSignal = case(
    isempty(UsualAuthMethod),   "No baseline",
    IsUnusualAuthMethod,        strcat("Unusual (expected: ", UsualAuthMethod, ")"),
    "Usual method"
  )
| extend IsHighRiskDetail = authenticationStepResultDetail in (
    "MFA denied; invalid verification code entered too many times",
    "MFA denied; user is blocked"
  )
| where ((IsHighRiskDetail and (not(IsKnownIP) or IsUnusualAuthMethod))
    or (not(IsHighRiskDetail) and not(IsKnownIP) and IsUnusualAuthMethod))
    and isnotempty(UsualAuthMethod)
| sort by TimeGenerated desc
| project TimeGenerated, UserPrincipalName, UserName, UPNSuffix, IPAddress, Location,
          RiskSignal = iff(IsKnownIP, "Known IP", "Unknown IP"),
          authenticationMethod, UsualAuthMethod, IsUnusualAuthMethod, AuthMethodSignal, IsHighRiskDetail,
          authenticationStepResultDetail, DeviceDetail
        

How the detection works

1. One baseline scan, shared across two lookups

The query wraps the 30-day baseline in a materialize() call and stores it as FilteredBaseline. Both the IP lookup and the auth method lookup draw from this same result, so Sentinel only scans SigninLogs once for the baseline period. Without materialize(), the engine would scan the table twice and double your query cost.

The baseline only includes records where:

2. Building the IP baseline

The KnownIPs block collects up to 500 individual IP addresses and up to 500 subnets per user using the extractSubnet() function.

Subnet matching exists because ISPs often rotate IP addresses within the same /24 block (IPv4) or /64 prefix (IPv6). A user signing in from a new address in the same home network is far less suspicious than one appearing from a foreign country. Keeping subnet history alongside exact IPs reduces false positives from routine address rotation while preserving geographic signal.

The function handles both IPv4 (first three octets) and IPv6 (first four groups). Heavily compressed IPv6 addresses such as ::1 are loopback addresses and should not appear in real sign-in traffic.

3. Building the auth method baseline

The BaselineAuthMethod block identifies each user's most-used MFA method over the 30-day window. It filters out Password and Previously satisfied from the authentication details, since those are not genuine second-factor methods. It then selects the method with the highest event count per user via arg_max().

The result is one row per user: their name and their typical MFA method. Someone who authenticated 200 times with Microsoft Authenticator push will show UsualAuthMethod = "Microsoft Authenticator". If their next denial event shows an SMS attempt, that registers as an anomaly.

4. Capturing recent denials

The main query looks at the last hour of SigninLogs where ResultType == 500121. It expands the AuthenticationDetails array to get the denial reason and filters to five specific strings:

The query then takes only the most recent denial per user with arg_max(TimeGenerated, ...). This keeps the output clean: one row per affected account, representing the latest event.

5. Joining baseline data and computing signals

Both KnownIPs and BaselineAuthMethod are joined to each denial event. The query then computes two boolean fields:

A third field, IsHighRiskDetail, flags the two denial reasons that indicate the most aggressive attacker behavior: too many invalid codes (active code guessing) and account blockage (lockout from sustained attempts).

6. The filter logic

The final where clause applies different thresholds depending on the denial reason:

The query also requires isnotempty(UsualAuthMethod). Accounts with no established MFA history are excluded entirely. New accounts or rarely-used accounts produce unreliable baselines and would dominate the output with false positives.


Tuning and operational guidance

Lookback windows

The 30-day baseline works well for users who sign in regularly. For accounts that are active only a few times a month, 30 days may not produce a meaningful baseline. Consider extending lookbackLong to 60 or 90 days, or adding a minimum event count before the auth method baseline is considered valid.

The 1-hour detection window keeps the rule tightly focused on active attack sessions. If you want to catch slower campaigns that spread attempts across several hours, you can increase this window, but expect more noise from benign user behavior in return.

Accounts without a baseline

The rule skips users with no UsualAuthMethod. This is intentional. You can handle these separately with a simpler rule that alerts on any 500121 event from an account with fewer than, say, 10 successful MFA sign-ins in the last 30 days. That keeps this rule focused on confirmed behavioral anomalies rather than sparse data.

Investigating an alert

When this rule fires, a few checks help you prioritize quickly:

Reducing false positives

The most common legitimate scenario that triggers this rule is a user signing in from an unfamiliar location with an MFA app reinstalled on a new device. A reinstall can change both the effective IP and the auth method registration state. Check whether the denial is isolated or part of a series: a single declined prompt from a new hotel IP is different from 20 failed attempts against the same account over 15 minutes.

You can exclude known corporate IP ranges or office egress addresses from the unknown-IP signal by adjusting the IsKnownIP logic to check against a Sentinel watchlist alongside the per-user history.


Closing thoughts

MFA is effective, but attackers who hold valid credentials treat it as an obstacle to work around, not a wall. Repeated push flooding works often enough to remain a standard technique, and it is cheap to execute at scale.

The strength of this detection is that it requires context, not just volume. A single denial from a known IP using the user's normal method does not fire the rule. A single denial from an unknown IP with an unfamiliar auth method does. That distinction is what makes the difference between a rule that generates investigations and one that generates alert fatigue.