Detecting Suspicious MFA Behavior in Microsoft Sentinel
Using a 30-Day IP and Auth Method Baseline to Surface MFA Fatigue Attacks
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.
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:
- A 30-day baseline of successful MFA sign-ins per user, used to build known IP and subnet sets and identify each user's typical authentication method.
- A 1-hour window of MFA denials (ResultType 500121) filtered to specific denial reasons that indicate active attack activity rather than routine user error.
- A scoring filter that only surfaces events where at least one anomaly signal is present, with stricter requirements for lower-risk denial types.
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:
ResultType == 0: successful sign-ins only.AuthenticationRequirement =~ "multiFactorAuthentication": sessions that actually required and completed MFA.- The time window falls between 30 days ago and 1 day ago, giving a stable history without mixing in today's activity.
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:
- MFA denied; invalid verification code entered too many times: the attacker is guessing codes, not just flooding push prompts.
- MFA denied; user declined the authentication: the user saw the prompt and explicitly rejected it.
- MFA denied; user hung up the phone call without succeeding the authentication: the attacker tried a phone call fallback.
- MFA denied; user is blocked: the account hit the MFA lockout threshold from repeated attempts.
- MFA denied: a generic denial where a more specific reason is not available in the log.
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:
IsKnownIP: true if the denying IP appears in the user's known IP set, or if its subnet appears in the known subnet set.IsUnusualAuthMethod: true if a baseline method exists and the current denial method differs from it.
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:
- High-risk denials (invalid codes, account blocked): alert if the IP is unknown or the auth method is unusual. One anomaly signal is enough because the denial reason itself already carries strong evidence of attacker activity.
- Lower-risk denials (user declined, hung up, generic): require the IP to be unknown and the auth method to be unusual. Both signals must be present to compensate for the weaker inherent signal of the denial type alone.
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:
- Is the account privileged? Admin accounts and service owners warrant immediate escalation regardless of other context.
- Does the IP resolve to a known datacenter, VPN provider, or anonymizing proxy? This strongly suggests attacker infrastructure rather than a traveling employee.
- Is
authenticationStepResultDetailshowing "user is blocked"? That means the account hit the MFA lockout threshold, confirming sustained and repeated attack activity. - Is
IsUnusualAuthMethodtrue? Check what method is being attempted. A fallback from Authenticator push to phone call or SMS is a concrete indicator of method-switch behavior. - Has the user's password been changed recently? A recent password change combined with a method-anomalous denial is a strong indicator of active credential compromise.
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.