Fixing the "Failed Logon Attempts by Valid Accounts within 10 mins" Analytic Rule

Fixing the "Failed Logon Attempts by Valid Accounts within 10 mins" Analytic Rule

Alright class.

On-premises this time, back in the Windows security log. The rule watches event 4625, a failed logon, and fires when a valid account fails 20 or more times in 10 minutes. It is one of the most deployed rules Microsoft ships and one of the most complained about, and the complaints are earned. It promises to count failures per valid account, then counts them in a way that struggles to reach its own threshold, and it treats a password that expired on Friday exactly the same as a password being guessed by a stranger.

The scope, though, is right, and the rebuild keeps it. One valid account taking sustained fire is a question worth answering on its own. The job here is not to turn the rule into something else. The job is to make it count properly and say something useful when it fires.

The Lookup Table Nobody Consults

The original builds a lovely table of nineteen substatus codes and their meanings, then uses it for display only. No decision touches it. That is a waste, because the substatus is the whole story of a 4625.

Bad password, unknown user name, account locked, account disabled, unauthorised workstation, logon outside permitted hours: this is what an attack looks like. Someone is presenting credentials that do not work, from somewhere they should not be, or against an account that has already slammed shut.

Expired password, expired account, must change password at next logon, clock skew, Netlogon not started, trust failure: this is what Tuesday looks like. A real account whose credential or plumbing lapsed, retrying on its own. A service account with a stale secret can rack up hundreds of these before lunch, and it is an operations ticket, not an incident.

The original counts both classes toward the same threshold, so the stale secret fires the rule daily, the threshold gets raised until the rule is deaf, and the team quietly disables it. The rebuild lets only attack-class failures feed the burst. Operational failures are still counted and reported on the row, because the analyst wants to see them, but they never trigger anything.

The Rebuild

One row per targeted account, scored on the shape of the failures against it, gated on the score:

| where RiskScore >= FireThreshold

The burst itself, 20 or more attack-class failures, carries weight 3 and fires alone, which preserves the original rule's core promise. Everything else stacks on top for triage: the targeted account holding a privileged role, three or more distinct origins, an external source, lockout reasons, multiple destination hosts, off-hours timing.

Two design points deserve a sentence each. A scored row only requires half the burst, 10 attack-class failures, so that a privileged account under external pressure does not have to wait for round numbers; an admin target plus an external origin fires at 12 failures, and it should. And the distributed-sources floor sits at three, not two, because two origins is what a phone and a laptop look like the morning after a password change.

Degradation is graceful. UEBA only supplies the roles behind the admin signal, so without it the rule still fires on the burst and every other indicator, and a missing lookup silences that one signal rather than inventing it. Where the event carries no usable IP address, the workstation name becomes the source, so an origin is never lost to an empty field.

The RiskIndicators Field

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

AttackFailureBurst | AdminAccountTargeted | MultipleSources | ExternalSource | LockoutReasons | MultipleHosts | OffHoursActivity

The combination to chase is AdminAccountTargeted with MultipleSources: several origins working the same privileged account is distributed guessing at a target worth having, and it goes to the top of the queue. Add ExternalSource and it goes above that.

The Blind Spots You Should Know About

A low-and-slow campaign against one account, a few failures an hour over days, sits under a 10 minute window by design; catch that with a scheduled hunt over a longer period, because a real-time rule and a patient attacker want different window sizes. The spray, as covered above, belongs to a sibling rule with a source pivot. Cloud sign-in brute force is a different table, SigninLogs, and a different sibling again.

NAT works against you here in a specific way: many machines behind one gateway collapse into a single source, so MultipleSources can stay silent even when the pressure is genuinely distributed. The total failure count and the targeted hosts on the row are your tiebreakers. And be aware that the bad-password class includes stale stored credentials, because a scheduled task holding last month's secret produces 0xc000006a exactly like an attacker does. The rule will fire on it, as the original description demands, but the indicators make the close fast: one source, one host, batch logon type, no spread. If the same Sam name keeps returning, fix its configuration or grant it a narrow exclusion.

MITRE Mappings for the Updated Rule

Tactic: Credential Access.

T1110.001 Brute Force, Password Guessing. The original maps the parent technique; the rebuild is specific about which child it can actually see, which is sustained guessing against a known valid account. T1110.003, Password Spraying, is deliberately out of scope and lives in the sibling rule.

Rule Settings

Run every 10 minutes; the DetectionWindow variable holds the burst to the last 10 minutes so nothing is double-counted across runs. The query period is 14 days only so the identity lookup for account roles resolves. Medium severity, with RiskScore and the indicators separating an attack from an operational grind. Alert per result; the query collapses to one row per account. Group by the Account entity over 6 hours so an account under fire across several runs is one incident.

Entity mapping:

  • TargetUserName to Account (Name), TargetDomainName to Account (NTDomain)
  • SampleIp to IP (Address)
  • SampleHost to Host (HostName)

Custom details: RiskScore, RiskIndicators, TargetAccount, HeldRoles, TotalFailures, AttackFailures, OperationalFails, LockoutFailures, DistinctSources, DistinctHosts, SourceList, SampleIp, TargetHosts, SubStatuses, LogonTypes, OffHoursCount.

KQL

// =====================================================================
// Failed Logon Burst Against a Valid Account - Windows
// =====================================================================
// Description : Rebuild of the Microsoft "Failed logon attempts by valid accounts within
//               10 mins" rule. Keeps the account as the subject, as the original intends,
//               and repairs the counting: failures are totalled per account across every
//               source, host and substatus instead of fragmenting across them, and only
//               attack-shaped reasons (bad password, unknown user, locked, disabled,
//               unauthorised workstation or hours) feed the burst. Operational reasons
//               (expired password or account, must-change, clock skew, service faults)
//               are reported as context and never trigger. Non-existent accounts remain
//               excluded; a name that does not exist is not a valid account under attack.
//               Scores privileged targets, distributed origins, external sources, lockout
//               pressure, multiple hosts and off-hours timing on top of the burst.
// Type        : Detection
//
// Tables      : SecurityEvent, WindowsEvent, IdentityInfo
// Connectors  : Security Events via AMA or Windows Security Events (SecurityEvent / WindowsEvent),
//               Microsoft Sentinel UEBA (IdentityInfo)
// License     : Microsoft Sentinel; Windows security event collection. UEBA only enriches
//               (roles held by the targeted account); every signal reads the event data
//
// Tuning      : - Set the rule query period to P14D; the 14d identity lookup only resolves if the rule looks back that far
//               - DetectionWindow - keep equal to the run frequency; the burst window
//               - AttackFailFloor - attack-class failures against one account that make it a burst
//               - LowActivityFloor - attack-class failures that earn an account a scored row at all
//               - MultiSourceFloor - distinct origins before the account counts as distributed;
//                 three, not two, because two origins is what a phone and a laptop look like
//                 after a password change
//               - BusinessStart / BusinessEnd - local working hours; TimeGenerated is UTC, shift to your tenant
//
// Known FPs   : - A cached credential retrying after a password change: bad-password reasons
//                 from one or two devices; below MultiSourceFloor it needs the full burst to
//                 fire, and the indicators name the shape instantly
//               - A service account with an expired secret: operational reasons dominate, the
//                 attack-failure count stays low, and the row never reaches scoring
//               - A scheduled task holding a stale secret: bad-password class from one source
//                 and one host, batch logon type; fix the configuration or exclude the Sam name
//
// Author      : Bartosz Wysocki | https://www.itprofessor.cloud
// Version     : 1.0 | 2026-07-18
// =====================================================================
let DetectionWindow = 10m;        // the burst window; keep equal to the run frequency
let IdentityLookback = 14d;       // full UEBA sync cycle, for roles held by the targeted account
let AttackFailFloor = 20;         // attack-class failures against one account that make it a burst
let LowActivityFloor = 10;        // attack-class failures that earn an account a scored row
let MultiSourceFloor = 3;         // distinct origins before the account counts as distributed
let BusinessStart = 7;
let BusinessEnd = 19;
let FireThreshold = 3;
// Scoring weights - the burst fires alone; everything else stacks for triage
let W_AttackBurst = 3;            // attack-class failures at or above AttackFailFloor
let W_AdminTargeted = 2;          // the targeted account holds a sensitive role
let W_MultiSource = 2;            // MultiSourceFloor or more distinct origins; distributed guessing
let W_ExternalSource = 2;         // at least one public origin; internet-facing pressure
let W_LockoutReasons = 1;         // locked or disabled reasons present; pressure past a lockout
let W_MultiHost = 1;              // the account was tried on more than one computer
let W_OffHours = 1;               // activity outside working hours
// Substatus codes that mean an authentication attack rather than an operational hiccup.
// 0xc0000064 (account does not exist) is excluded upstream, as the original excludes it:
// a name that does not exist is not a valid account, and its failures belong to an
// enumeration detection, not this one
let AttackSubStatus = dynamic([
"0xc000006a",   // bad password
"0xc000006d",   // bad user name or password
"0xc000006e",   // unknown user name or bad password
"0xc0000070",   // unauthorised workstation
"0xc000006f",   // logon outside authorised hours
"0xc0000234",   // account locked
"0xc0000072",   // account disabled
"0xc000015b",   // logon type not granted
"0xc0000413"    // blocked by authentication firewall
]);
let OperationalSubStatus = dynamic([
"0xc0000071",   // expired password
"0xc0000193",   // expired account
"0xc0000224",   // must change password at next logon
"0xc000005e",   // no logon servers available
"0xc0000133",   // clock skew between DC and client
"0xc0000192",   // Netlogon service not started
"0xc000018c",   // trust relationship failed
"0xc00000dc",   // Sam server in the wrong state
"0xc00002ee",   // an error occurred during logon
"0xc0000225"    // a Windows bug, per Microsoft, and not a risk
]);
let LockoutSubStatus = dynamic(["0xc0000234", "0xc0000072"]);
let SensitiveRoles = dynamic([
"Global Administrator", "Privileged Role Administrator", "Privileged Authentication Administrator",
"Security Administrator", "User Administrator", "Domain Admins", "Enterprise Admins",
"Exchange Administrator", "SharePoint Administrator", "Intune Administrator", "Account Operators"
]);
// Normalised 4625 events from both collectors, one shape. LogonType is cast to int on both
// sides; leave int against string across the fuzzy union and the column splits into
// LogonType_int and LogonType_string, and later references fail to resolve
let FailedLogons = materialize(union isfuzzy=true
    (SecurityEvent
        | where TimeGenerated > ago(DetectionWindow)
        | where EventID == 4625 and AccountType =~ "User"
        | where SubStatus !~ "0xc0000064" and TargetAccount !in ("\\", "-\\-")
        | project TimeGenerated, TargetAccount, TargetUserName, TargetDomainName, Computer,
                  SubStatus = tolower(SubStatus), IpAddress, WorkstationName,
                  LogonType = toint(LogonType)),
    (WindowsEvent
        | where TimeGenerated > ago(DetectionWindow)
        | where EventID == 4625 and not(EventData has "0xc0000064")
        | extend TargetUserSid = tostring(EventData.TargetUserSid)
        | extend AccountType = case(tostring(EventData.TargetUserName) endswith "$" or TargetUserSid in ("S-1-5-18", "S-1-5-19", "S-1-5-20"), "Machine", isempty(TargetUserSid), "", "User")
        | where AccountType =~ "User"
        | extend TargetUserName = tostring(EventData.TargetUserName)
        | extend TargetDomainName = tostring(EventData.TargetDomainName)
        | extend TargetAccount = strcat(TargetDomainName, "\\", TargetUserName)
        | where TargetAccount !in ("\\", "-\\-")
        | project TimeGenerated, TargetAccount, TargetUserName, TargetDomainName, Computer,
                  SubStatus = tolower(tostring(EventData.SubStatus)),
                  IpAddress = tostring(EventData.IpAddress),
                  WorkstationName = tostring(EventData.WorkstationName),
                  LogonType = toint(EventData.LogonType))
    | extend SourceIp = iff(isnotempty(IpAddress) and IpAddress !in ("-", "::1", "127.0.0.1"), IpAddress, "")
    | extend Source = iff(isnotempty(SourceIp), SourceIp, strcat("host:", tolower(WorkstationName)))
    | where isnotempty(Source) and Source != "host:"
    | extend IsAttackFail = SubStatus in (AttackSubStatus)
    | extend IsOperationalFail = SubStatus in (OperationalSubStatus)
    | extend IsLockout = SubStatus in (LockoutSubStatus)
    | extend IsExternalIp = isnotempty(SourceIp) and not(ipv4_is_private(SourceIp))
    | extend HourOfDay = datetime_part("Hour", TimeGenerated)
    | extend OffHoursEvent = HourOfDay < BusinessStart or HourOfDay >= BusinessEnd or dayofweek(TimeGenerated) in (0d, 6d)
);
// Roles held by each account, from UEBA. Absent UEBA the lookup returns nothing and the
// admin signal stays silent; every other signal reads the event data
let IdentityContext = IdentityInfo
    | where TimeGenerated > ago(IdentityLookback)
    | summarize arg_max(TimeGenerated, AssignedRoles) by AccountKey = tolower(AccountName);
// One row per targeted account, scored on the shape of the failures against it
FailedLogons
| summarize
    StartTime = min(TimeGenerated),
    EndTime = max(TimeGenerated),
    TotalFailures = count(),
    AttackFailures = countif(IsAttackFail),
    OperationalFails = countif(IsOperationalFail),
    LockoutFailures = countif(IsLockout),
    DistinctSources = dcount(Source),
    DistinctHosts = dcount(Computer),
    SourceList = make_set(Source, 15),
    TargetHosts = make_set(Computer, 15),
    SubStatuses = make_set(SubStatus, 15),
    LogonTypes = make_set(LogonType, 10),
    ExternalCount = countif(IsExternalIp),
    SampleIp = take_anyif(SourceIp, isnotempty(SourceIp)),
    SampleHost = take_any(Computer),
    OffHoursCount = countif(OffHoursEvent)
  by TargetAccount, TargetUserName, TargetDomainName
| where AttackFailures >= LowActivityFloor
| extend AccountKey = tolower(TargetUserName)
| lookup kind=leftouter IdentityContext on AccountKey
| extend HeldRoles = tostring(AssignedRoles)
| extend sBurst = AttackFailures >= AttackFailFloor
| extend sAdminTargeted = HeldRoles has_any (SensitiveRoles)
| extend sMultiSource = DistinctSources >= MultiSourceFloor
| extend sExternal = ExternalCount > 0
| extend sLockout = LockoutFailures > 0
| extend sMultiHost = DistinctHosts > 1
| extend sOffHours = OffHoursCount > 0
| extend RiskScore =
      iff(sBurst, W_AttackBurst, 0)
    + iff(sAdminTargeted, W_AdminTargeted, 0)
    + iff(sMultiSource, W_MultiSource, 0)
    + iff(sExternal, W_ExternalSource, 0)
    + iff(sLockout, W_LockoutReasons, 0)
    + iff(sMultiHost, W_MultiHost, 0)
    + iff(sOffHours, W_OffHours, 0)
| where RiskScore >= FireThreshold
| extend Risk_1 = iff(sBurst, "AttackFailureBurst", "")
| extend Risk_2 = iff(sAdminTargeted, "AdminAccountTargeted", "")
| extend Risk_3 = iff(sMultiSource, "MultipleSources", "")
| extend Risk_4 = iff(sExternal, "ExternalSource", "")
| extend Risk_5 = iff(sLockout, "LockoutReasons", "")
| extend Risk_6 = iff(sMultiHost, "MultipleHosts", "")
| 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,
    TargetAccount, TargetUserName, TargetDomainName, HeldRoles,
    TotalFailures, AttackFailures, OperationalFails, LockoutFailures,
    DistinctSources, DistinctHosts, SourceList, SampleIp, SampleHost,
    TargetHosts, SubStatuses, LogonTypes, OffHoursCount
| sort by RiskScore desc, AttackFailures 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

Set the floors to your environment first. AttackFailFloor at 20 preserves the original threshold, LowActivityFloor at 10 gives privileged and external combinations a way in below it, and both may need raising in a large domain with chatty legacy applications.

Run it over the last day and read the substatus sets and indicators before touching anything else. What fires should be accounts taking bad-password and unknown-user pressure. If the same account keeps returning from one source with batch logon types, its stored credential is stale, and that is a configuration fix or a narrow exclusion, not a tuning failure.

Confirm your role list. The heaviest amplifier is a privileged account being the target, so make sure SensitiveRoles matches the groups that actually matter in your directory, including your on-premises Domain Admins and Account Operators.

Pair it with a success check. This rule tells you an account is under fire; a companion that looks for a successful 4624 for the same account from the same source in the same window tells you the fire landed, which is the alert you never want to miss. And build the spray sibling with the source pivot, because that is the half of brute force this rule correctly refuses to pretend it covers.

Class dismissed.

Consent Preferences