DEV Community

Alan Conlon
Alan Conlon

Posted on Originally published at alanconlon.com on

Hunting Password Spray in Sentinel: One Password Against a Thousand Doors

Everyone pictures a password attack as hammering one account: thousands of guesses against a single login until something gives. That’s brute force, and it’s easy to catch, a pile of failures on one account trips any lockout worth its salt. Password spray is the smarter cousin, and it’s built specifically to slide under exactly that defence.

The trick is to flip the ratio. Instead of many passwords against one account, spray tries one password, a common one, Winter2026! or the company name with a number, against many accounts. One attempt each, or two, spread across hundreds of users. No single account sees enough failures to lock, so smart lockout never fires, and the attacker only needs one person in the whole directory to have picked the guessable password. In an organisation of any size, someone always has.

It’s a numbers game, and the numbers favour the attacker. Which is exactly why it’s still one of the most common ways in, and why it’s worth being able to see.

What does password spray look like in the sign-in logs?

Because spray inverts the ratio, it inverts the shape in the logs too. Brute force is a tall spike on one account. Spray is a wide, shallow smear: one source touching a great many distinct accounts, each only once or twice, in a short window. That breadth is the tell. Ordinary failed sign-ins are scattered across the day and across sources; spray is one origin reaching for dozens or hundreds of different usernames in minutes.

The failures land in SigninLogs with a result code, and the one that matters here is 50126, the code for an invalid username or password. That’s the bad-guess code, the one a wrong password produces.

Brute force is many failed attempts against one account, a tall narrow spike. Password spray is one source failing against many distinct accounts, one or two tries each, a wide shallow smear. The breadth of distinct users is the signal.

Brute force is tall and narrow. Spray is wide and shallow. Count the distinct users, not the attempts.

Look before you detect

Same discipline as the primer and every post since: look at the raw failures in your own tenant before you try to be clever, because you can’t tell an attack from a busy Monday until you’ve seen a normal one.

SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == "50126"
| take 20

Enter fullscreen mode Exit fullscreen mode

Run that, read the rows. You’ll see the ordinary background of fat-fingered passwords and stale cached credentials on people’s phones. Note what a normal source looks like, one user, from one IP, failing a couple of times and then succeeding. That’s the baseline the attack has to stand out from.

Now count the breadth

The detection isn’t “how many failures”, it’s “how many different accounts from one source”. So we count distinct users per IP inside a time window:

SigninLogs
| where TimeGenerated > ago(1d)
| where ResultType == "50126"
| summarize
 DistinctUsers = dcount(UserPrincipalName),
 Attempts = count()
 by IPAddress, bin(TimeGenerated, 1h)
| where DistinctUsers >= 15
| sort by DistinctUsers desc

Enter fullscreen mode Exit fullscreen mode

Read it as a sentence. Take the last day of bad-password failures, count the distinct users and total attempts per IP per hour, and show me any source that failed against fifteen or more different accounts in a single hour. dcount is the operator doing the real work: it counts distinct users, which is the breadth that separates spray from one person locked out of their own account. That user fails fifteen times against one username; a sprayer fails once against fifteen usernames. Same fifteen failures, completely different dcount.

The threshold of fifteen is a starting point, not gospel. Your baseline from the first query is the guide, a big tenant with a chatty VPN egress might need it higher, a small one lower. Tune it to your own noise.

The refinement that turns a hunt into an incident

A source spraying and failing is an attack attempt. A source that sprayed and then, somewhere in the same window, succeeded against one of those accounts is a breach in progress, because it means one of the guessed passwords was right.

let Lookback = 1d; // how far back to look
let Threshold = 15; // distinct accounts before a source counts as spraying
let SprayIPs = SigninLogs
 | where TimeGenerated > ago(Lookback)
 | where ResultType == "50126"
 | summarize DistinctUsers = dcount(UserPrincipalName), FirstSpray = min(TimeGenerated) by IPAddress
 | where DistinctUsers >= Threshold;
SigninLogs
| where TimeGenerated > ago(Lookback)
| where ResultType == "0"
| join kind=inner SprayIPs on IPAddress
| where TimeGenerated > FirstSpray // only successes after the spray began
| project TimeGenerated, UserPrincipalName, IPAddress,
 DistinctUsers, Location, AppDisplayName
| sort by TimeGenerated desc

Enter fullscreen mode Exit fullscreen mode

Two ideas you’ll recognise from the MFA fatigue hunt. let names the set of spraying IPs, the sources that failed broadly. Then join matches those same IPs against successful sign-ins (ResultType == "0"), and FirstSpray keeps only the successes that came after the spray began. What falls out is the list that matters: an account that logged in successfully, after the spray started, from an IP that was spraying the rest of your directory. That’s not a coincidence you explain away. That’s the one they got. The one exception is a shared NAT or VPN egress IP, which can still match, so check the source before you call it.

If that query returns a row, treat it as compromise: revoke the sessions, reset the credential, and check what that account touched before you do anything else.

Both spray queries are in the free queries repo, commented and with the thresholds pulled out as let values at the top.

What it misses

Honesty about the gaps, as always. The big one is distributed spray: an attacker who sprays from a botnet, a different IP per account, defeats the per-IP count entirely, because no single source touches enough users to cross the threshold. Catching that needs a different angle, counting the breadth per password or per time window across all sources rather than per IP, and it’s a harder, noisier hunt. This query catches the common, cheap version, the one source spraying away, which is still most of what you’ll see.

It also leans on 50126 specifically, and a spray that trips smart lockout will start throwing 50053 instead, so a truly patient attacker pacing under the lockout stays on 50126 and a clumsy one shifts codes. Worth watching both. And none of it helps if the sprayed account had no MFA behind the password, which is the real lesson underneath the query: spray works because a password on its own is still a single point of failure. Blocking legacy authentication with Conditional Access takes away the endpoints where that’s still true.

Promote it slowly

You know the routine. Run it in the logs for a week or two, watch what it flags, learn which of your egress IPs are just busy rather than hostile, tune the threshold and window, and only then wire the refinement query into a scheduled analytics rule that alerts on a spray-then-success. The broad count is for hunting; the join is for paging someone.

Password spray has been the opening move in more breaches than almost any other technique, and it survives entirely on the gap between “one account’s lockout” and “the whole directory’s failure rate”. Close that gap by counting breadth instead of depth, and the wide shallow smear that was invisible to per-account defences lights right up.


Originally published at alanconlon.com, where I post practical security, Azure and data write-ups every other Tuesday.

Top comments (0)