← Back to Blog

One Failed Login Is Noise. The Same Failure Across Sixty Accounts Is a Spray Your Rule Can't Count.

21 July 2026 Detection Engineering 9 min read
ONE BASE RULE, TWO GROUPINGS, TWO DIFFERENT ATTACKS failed_logon one failed sign-in event event_count • group-by user How many failures per account? alice: 12 failures → FIRES Catches BRUTE FORCE A spray shows 1 per account here. Invisible. value_count • group-by source How many distinct accounts per source? 10.0.0.9: 60 accounts → FIRES Catches PASSWORD SPRAY Brute force shows 1 distinct account here.

A brute-force attempt and a password spray land in your logs as the same event: a failed sign-in. Same table, same fields, same result code. So most teams write one detection with one threshold and believe they have covered both. They have covered one. The other walks straight past, because the two attacks are the same event arranged into opposite shapes, and a single threshold can only see one shape at a time.

Here is the difference in one line. Brute force is many failures against one account. Password spray is one failure against many accounts. Both are just failed sign-ins, but the pattern that makes each one an attack lives in a different dimension, and if your rule counts the wrong dimension it will sit green on the dashboard while the spray runs to completion.

The rule that can't count

Start with the event everything is built from: a single failed sign-in. On its own it is worth nothing. People fat-finger passwords every morning, phones retry stale tokens, an app holds an expired credential. A rule that alerts on one failed sign-in is pure noise, so nobody ships one. That is correct, and it is also the problem, because the moment you decide one failure is not worth alerting on, you have decided you need to count. Counting is exactly what a single-event rule cannot do. It looks at one event and answers yes or no. It has no memory of the event before it and no view of the events beside it.

So you write the base rule to recognize the event and stop there, deliberately quiet, and you let a correlation do the counting on top of it. The base rule is the same for both attacks. It matches a failed sign-in and nothing more.

# base rule: one failed sign-in. Quiet on its own, by design.
title: Failed sign-in
name: failed_signin
logsource:
    product: azure
    service: signinlogs
detection:
    selection:
        ResultType|gt: 0          # non-zero result = a failure
    condition: selection

That base rule gives a correlation something to count. What you count, and what you group the count by, is the whole detection.

Brute force: count the same thing

Brute force hammers one account. The signal is the number of failures for a single user in a short window, so you count events per account. In Sigma that is an event_count correlation that references the base rule and groups by the user.

# brute force: many failures against ONE account, short window
title: Brute force against a single account
correlation:
    type: event_count
    rules: [failed_signin]
    group-by: [UserPrincipalName]
    timespan: 5m
    condition: {gte: 10}
level: high

The same logic in KQL against Sentinel is a count per user, bucketed into five-minute windows. The comment states the hypothesis, so the next analyst reads the intent behind the query rather than reverse-engineering it from the syntax.

// Hypothesis: >= 10 failures for one account in 5 min = brute force
SigninLogs
| where ResultType != 0                              // failed sign-ins only
| summarize failures = count() by UserPrincipalName, bin(TimeGenerated, 5m)
| where failures >= 10                              // tune above your normal retry noise

And the same shape in Splunk SPL, counting events per user per window.

``` >= 10 failures for one account in 5 min = brute force ```
`azure_signin` ResultType!=0
| bin _time span=5m
| stats count as failures by UserPrincipalName _time
| where failures>=10

This works, and it will catch someone parked on one account trying password after password. Now watch what happens when the attacker changes shape.

Password spray: count something different

A spray does the opposite of brute force on purpose. Instead of many passwords against one account, it tries one or two common passwords against hundreds of accounts. It stays low on any single account for a specific reason, and that reason is the number you need to know.

The benchmark

Microsoft Entra ID smart lockout locks an account after a default of 10 failed sign-ins on Azure Public tenants (three on US Government tenants), per Microsoft's own documentation. A competent spray knows this and stays under it, usually one to five attempts per account. So your brute-force rule set at 10 per account never trips, because no account ever reaches 10. The very control that stops brute force is the reason a per-account threshold is blind to spray: both the lockout and your detection are counting the dimension the spray refuses to fill.

The failures are spread across accounts, so counting per account gives you a pile of ones. The signal is not how many times one account was hit. It is how many distinct accounts one source touched. That is a different question, and Sigma has a different correlation type for it: value_count, which counts the distinct values of a field rather than the number of events. You group by the source and count distinct users.

# spray: ONE source failing against MANY distinct accounts
title: Password spray from a single source
correlation:
    type: value_count
    rules: [failed_signin]
    group-by: [IPAddress]
    timespan: 1h
    condition: {gte: 20, field: UserPrincipalName}
level: high

In KQL the operator that carries the whole detection is dcount: distinct accounts per source, not raw failures.

// Hypothesis: one source failing against >= 20 distinct accounts in 1h = spray
SigninLogs
| where ResultType != 0
| summarize sprayedAccounts = dcount(UserPrincipalName) by IPAddress, bin(TimeGenerated, 1h)
| where sprayedAccounts >= 20                     // a real user fails against 1, not 20

And in SPL, the distinct count dc does the same work.

``` one source vs >= 20 distinct accounts in 1h = spray ```
`azure_signin` ResultType!=0
| bin _time span=1h
| stats dc(UserPrincipalName) as sprayed_accounts by IPAddress _time
| where sprayed_accounts>=20

The base rule is identical to the brute-force case. The only thing that changed is what you counted and what you grouped by. That change is the entire detection.

Each attack hides in the other's blind spot

Put the two side by side and the reason a single threshold cannot cover both becomes obvious. Run the brute-force rule against a spray and every account shows one or two failures, far under 10, so it fires on nothing. Run the spray rule against a brute-force attempt and the source touched exactly one distinct account, far under 20, so it fires on nothing either. Neither rule is broken. Each is measuring the dimension the other attack deliberately keeps small.

The pattern to remember

Ask which question the attack poses. How many times against one target is a count of events, so it is event_count grouped by the target. How many distinct targets from one source is a count of unique values, so it is value_count grouped by the source, counting the field that fans out. Get that question wrong and the rule converts cleanly, runs without error, and detects nothing, because it is counting in a dimension the attack was designed to leave flat.

This is why the grouping key is not a detail you tune later. It is the detection. The threshold and the window matter too, and both need setting against your own baseline rather than a number copied from a blog, including this one. Measure a normal hour: how many distinct accounts does a busy office VPN egress touch, how many failures does a flaky service account rack up. Set the threshold above that and below the attack, and where those overlap, accept that you will trade some false positives for coverage or some coverage for quiet, and make that trade on purpose.

One honest limit: a correlation reasons over a fixed time window, so a patient spray that spreads across many hours, or a distributed spray coming from a fresh IP each attempt, can slip under a per-source, per-hour count. The fix is not a bigger threshold. It is a second correlation that groups by something the attacker cannot rotate as cheaply as an IP, such as the target account set or the application, over a longer window. The counting model is the same. Only the grouping key moves.

What to do this week

  1. Find your current failed-sign-in detection and read one line: what does it group by, and does it count raw events or distinct accounts. If it counts failures per user, you have brute-force coverage and a spray blind spot. Confirm which one you actually have.
  2. Run the dcount KQL above against your own SigninLogs for the last 30 days, with the threshold dropped to 10, and look at what comes back. Distinct-account counts per source that you did not expect are either a spray you missed or a NAT egress you now need to baseline.
  3. Ship the value_count spray correlation as a distinct rule, not a tweak to your brute-force rule. They answer different questions and need different thresholds and windows. One rule cannot serve both.
  4. Add a second, slower spray correlation grouped by the target application over a longer window, to catch the distributed and low-and-slow variants a per-IP hourly count misses.
  5. Anchor the work in the ecosystem. The technique is T1110.003 Password Spraying under T1110 Brute Force; Atomic Red Team T1110.003 gives you a safe way to generate the events and prove the rule fires; and the SigmaHQ correlation reference is the authority for the event_count and value_count syntax.

Brute force and password spray are the same event wearing opposite shapes, and the shape is the whole detection. Count per account and you catch the one that hammers a door. Count distinct accounts per source and you catch the one that tries every door once. You need both, because the attacker will pick whichever one you are not counting.

Ridgeline Cyber Defence Written by security practitioners. Published weekly on Tuesdays.

Related Articles

2 June 2026

You Deployed 350 Detection Rules. Only 50 Fire Regularly. Are the Other 300 Working?

Most detection libraries are full of rules that have never fired. Silent rules aren't coverage. They're assumptions. Her

14 July 2026

Your Sigma Rule Converts Cleanly and Still Never Fires. Here's the Test That Catches It.

A one-character field typo passes sigma check, converts to valid KQL and SPL, and silently matches nothing. Static valid

9 June 2026

Your Noisy Rule Has 200 False Positives a Day. Don't Suppress the Field That's Making Noise.

The fastest way to quiet a noisy detection rule is to exclude the field generating the noise. It's also the fastest way