Entra ID Password Spray Detection with Sign-In Logs

Entra ID password spray detection is one of those problems that looks solved until you actually go looking for it. Spraying is deliberately quiet — one or two common passwords tried against hundreds of accounts, slow enough that no single user ever trips account lockout. I went hunting for it in my own tenant expecting the built-in alerts to have it covered, and found that the signal is there in the sign-in logs the whole time; you just have to aggregate across accounts instead of looking at them one at a time. This post is how I do that with KQL, Microsoft Graph, and a couple of Conditional Access changes that matter more than the detection itself.

Key Takeaways

  • Password spray hides from per-account thinking because each user sees only one or two failures — Entra ID password spray detection works by aggregating failed sign-ins by source IP and counting how many distinct accounts were targeted.
  • The Entra sign-in logs carry the whole story in the result error codes: 50126 for a wrong password, 50053 for smart lockout, and a 0 success from the same IP is the sign-in you actually care about.
  • You can hunt for this without Microsoft Sentinel — the same logic runs as a KQL query in a Log Analytics workspace, in Defender XDR Advanced Hunting, or through the Microsoft Graph PowerShell SDK.
  • Legacy authentication endpoints are the preferred spray target because they bypass multi-factor authentication, so blocking legacy auth with Conditional Access does more for you than any detection rule.
  • The built-in password spray risk detection in Microsoft Entra ID Protection is good, but it needs Entra ID P2 and it is not a substitute for knowing how to read the raw logs yourself.

Environment

  • Microsoft Entra ID tenant on Microsoft 365 E5, which includes Entra ID P2 — required for the Identity Protection risk detections and for 30-day sign-in log retention.
  • Sign-in logs queried three ways: the Entra admin center, a Log Analytics workspace fed by Entra diagnostic settings, and Microsoft Defender XDR Advanced Hunting.
  • Microsoft Graph PowerShell SDK (Microsoft.Graph.Authentication and Microsoft.Graph.Reports modules) for pulling sign-ins without a workspace.
  • Tables referenced: SigninLogs and AADNonInteractiveUserSignInLogs in Log Analytics; AADSignInEventsBeta in Advanced Hunting.
  • This is detection and hardening work against a tenant I own. Run it against your own environment, not someone else's.

The Problem

Brute force is loud — many passwords against one account, fast, and Entra's smart lockout kills it after ten bad guesses. Password spray inverts the shape of the attack to dodge exactly that defence. Instead of many passwords against one account, it tries one password — Summer2026!, Welcome1, the company name plus a year — against every account it can enumerate, then waits, then tries the next password. No individual account accumulates enough failures to lock, so the per-user view in the portal shows nothing alarming. One failed sign-in for Alice, one for Bob, one for Carol. Boring, until you notice all three came from the same IP in the same ninety seconds.

That is the whole detection problem in one sentence: the signal does not live in any single account, it lives in the aggregate. The Entra sign-in logs record every attempt with a result error code, a source IP, and the client app used, which is everything I need. What they do not do well is the in-portal experience of grouping thousands of failures by IP and telling me "this address tried 240 different users and got one of them." For that I have to write the query myself, and the good news is the query is short.

Microsoft does ship a password spray risk detection as part of Entra ID Protection, and it is worth turning on. But it requires Entra ID P2, it runs on Microsoft's schedule rather than mine, and it is a black box — it tells me a user is at risk without showing me the 240 sibling failures that led there. I would rather have both: the managed detection for coverage, and the raw query for when I need to actually understand what happened.

The Solution — Entra ID Password Spray Detection in the Sign-In Logs

Step 1 — Learn the sign-in error codes that matter

Every failed sign-in in Entra ID carries a result error code, and a handful of them are the entire vocabulary of a spray. Knowing these by sight is what turns a wall of log rows into a story:

  • 50126 — invalid username or password. This is the bread-and-butter spray failure: the username exists, the password was wrong.
  • 50053 — the account is locked by smart lockout, or the IP is locked out after too many bad attempts. A burst of these means the spray is hitting hard enough to trip the threshold.
  • 50034 — the user does not exist. A pile of these from one IP is account enumeration, usually the reconnaissance step before the spray proper.
  • 50055 / 50057 — expired password / disabled account. The credentials were close to right, which is worth a second look.
  • 50076 / 50074 — success, but multi-factor authentication is now required. This is critical: the password was correct. The spray found a valid credential and only MFA stood in the way.
  • 0 — success, no further challenge. From an IP that just failed against 200 other accounts, this is the breach, not a detection.

The pattern I am looking for is a source IP producing a large number of 50126 results spread thinly across many distinct user principal names — and then any 0, 50074, or 50076 from that same IP.

Step 2 — Aggregate failed sign-ins by source IP with KQL

This is the core query. It groups failed credential attempts by source IP and client app, counts how many distinct accounts each IP went after, and keeps only the addresses that targeted many accounts with few attempts each — the defining shape of a spray:

SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType == "50126"   // invalid username or password
| summarize FailedAttempts = count(),
            TargetedAccounts = dcount(UserPrincipalName),
            Accounts = make_set(UserPrincipalName, 50),
            FirstSeen = min(TimeGenerated),
            LastSeen  = max(TimeGenerated)
        by IPAddress, AppDisplayName, ClientAppUsed
| extend AttemptsPerAccount = round(todouble(FailedAttempts) / TargetedAccounts, 2)
| where TargetedAccounts >= 10 and AttemptsPerAccount < 5
| sort by TargetedAccounts desc

The two thresholds are the part you tune to your tenant. TargetedAccounts >= 10 filters out the everyday "user fat-fingered their password twice" noise; a real spray hits dozens to hundreds. AttemptsPerAccount < 5 is what separates spray from brute force — a spray keeps the per-account count low on purpose, so a high ratio is a different problem. On a large tenant, raise the account threshold; on a small one, lower it and watch for false positives from a misconfigured VPN or a shared NAT egress IP where many legitimate users genuinely appear behind one address.

One accuracy note worth internalising: in SigninLogs the ResultType column is a string, so compare it to "50126" in quotes, not the bare integer. Comparing against a number sometimes works through implicit conversion and sometimes silently returns nothing, which is the worst kind of bug in a detection query.

Step 3 — Find the sign-in that actually succeeded

Catching the attempts is useful. Catching the one that worked is the point. This query takes the IPs that look like sprayers over the last week and pulls any successful or MFA-prompted sign-in from those same addresses:

let SprayIPs =
    SigninLogs
    | where TimeGenerated > ago(7d)
    | where ResultType == "50126"
    | summarize TargetedAccounts = dcount(UserPrincipalName) by IPAddress
    | where TargetedAccounts >= 10
    | project IPAddress;
SigninLogs
| where TimeGenerated > ago(7d)
| where IPAddress in (SprayIPs)
| where ResultType in ("0", "50074", "50076")   // success, or success pending MFA
| project TimeGenerated, UserPrincipalName, IPAddress, AppDisplayName,
          ClientAppUsed, ResultType, ResultDescription
| sort by TimeGenerated asc

A 0 here means an account fell and nothing stopped it — treat it as an active compromise, reset the credential, and revoke the sessions. A 50074 or 50076 means the password was correct but MFA caught the attacker at the door. That is a near miss, not a save: the password is now known to an adversary and needs rotating regardless of how well MFA held. The SIEM correlation rules approach I wrote about previously is the natural home for this — chaining "many 50126 from an IP" to "a later success from the same IP" is exactly the kind of multi-stage rule that turns these two ad-hoc queries into one scheduled detection.

Step 4 — Do not forget the non-interactive sign-in logs

A lot of spraying targets legacy authentication protocols — POP, IMAP, SMTP AUTH, and the catch-all "Other clients" — precisely because legacy auth predates modern controls and frequently bypasses multi-factor authentication entirely. Those attempts often land in AADNonInteractiveUserSignInLogs, not SigninLogs, and if you only watch the interactive table you will miss them. The same aggregation works against the non-interactive table; just swap the table name in Step 2. In Defender XDR Advanced Hunting the interactive equivalent is AADSignInEventsBeta, though note it is a beta table and does not yet cover non-interactive sign-ins as completely as a Log Analytics workspace fed directly from Entra diagnostic settings does.

Step 5 — Hunt sign-ins with Microsoft Graph when you have no workspace

Not every tenant streams sign-in logs to Log Analytics. When all I have is the Entra audit data itself, the Microsoft Graph PowerShell SDK pulls the same logs and I do the grouping locally:

Connect-MgGraph -Scopes "AuditLog.Read.All", "Directory.Read.All"

$start = (Get-Date).AddDays(-1).ToString("yyyy-MM-ddTHH:mm:ssZ")
$failures = Get-MgAuditLogSignIn -Filter "createdDateTime ge $start and status/errorCode eq 50126" -All

$failures |
    Group-Object IpAddress |
    Where-Object { ($_.Group.UserPrincipalName | Select-Object -Unique).Count -ge 10 } |
    ForEach-Object {
        [pscustomobject]@{
            IPAddress        = $_.Name
            FailedAttempts   = $_.Count
            TargetedAccounts = ($_.Group.UserPrincipalName | Select-Object -Unique).Count
        }
    } |
    Sort-Object TargetedAccounts -Descending |
    Format-Table -AutoSize

Accessing sign-in logs through Graph requires at least Entra ID P1 on the tenant — the reporting API is gated behind the same licence that unlocks the logs in the portal. The status/errorCode filter is an OData query against the nested status object, and -All pages through the full result set rather than stopping at the first 1,000 rows, which matters when a spray generates tens of thousands of failures. If you want to ship the output to a file for review, the same patterns I use for on-prem event log analysis apply — pipe the objects to Export-Csv and read them somewhere other than a console window.

Step 6 — Harden so the next spray fails by design

Detection without hardening just gives you a better seat for watching the breach. The changes that actually reduce risk, in rough order of impact:

  • Block legacy authentication with a Conditional Access policy. Legacy protocols cannot do modern MFA, so they are where spraying succeeds. Microsoft now blocks legacy auth by default for new tenants, but older tenants frequently still allow it somewhere.
  • Require multi-factor authentication via Conditional Access for all users. A correct password is worth far less when it is only the first of two factors.
  • Tune smart lockout. The default is ten failed attempts before a one-minute lockout that grows on repeat offences. Lowering the threshold raises the cost of spraying, at the price of a few more legitimate lockouts.
  • Turn on Entra ID Password Protection with a custom banned password list. If Summer2026! and your company name can never be set as a password, the most effective spray candidates stop working.
  • Enable the Identity Protection sign-in risk and user risk policies if you have P2, so a flagged risky sign-in is forced into MFA or blocked automatically rather than waiting for me to read a query.

Frequently Asked Questions

Does Entra ID detect password spray automatically?

Partly. Microsoft Entra ID Protection includes a dedicated password spray risk detection that flags affected users, but it requires an Entra ID P2 licence and runs on Microsoft's own cadence. It is good coverage to have enabled, but it does not show you the underlying failed sign-ins, so the manual KQL hunting above stays useful for investigation.

What sign-in error code indicates a password spray?

The dominant one is 50126 — "invalid username or password" — which means a real account was hit with the wrong password. Watch alongside it for 50053 (smart lockout triggered) and 50034 (the account does not exist, i.e. enumeration). A 0, 50074, or 50076 from the same source IP signals the password was actually correct.

Can I detect password spray without Microsoft Sentinel?

Yes. The KQL aggregation runs in any Log Analytics workspace and in Defender XDR Advanced Hunting against AADSignInEventsBeta. With no workspace at all, the Microsoft Graph PowerShell SDK pulls the same sign-in logs for local grouping, provided the tenant has at least Entra ID P1 so the reporting API is available.

Why does password spray get past account lockout?

Smart lockout counts failures per account. A spray deliberately keeps the per-account failure count low — often a single attempt — by trying one password across many accounts before cycling to the next password. No individual account reaches the lockout threshold, so the attack stays under the per-account radar while the aggregate across accounts is huge.

Does blocking legacy authentication stop password spray?

It does not stop the attempts, but it removes the path where they succeed. Legacy protocols like IMAP and SMTP AUTH cannot enforce multi-factor authentication, so they are where a correct sprayed password actually grants access. Blocking legacy auth with Conditional Access forces every sign-in through modern flows that can demand a second factor.

Conclusion

Password spray is not a sophisticated attack. It is patient, and patience is enough to beat defences that only ever look at one account at a time. The fix for the detection side is genuinely simple once you accept that the signal is in the aggregate: group failed sign-ins by source IP, count distinct targets, and flag the addresses going wide and shallow. Two short KQL queries and a Graph one-liner cover the cases I run into.

The honest caveat is that detection is the smaller half of the work. Finding the spray after a credential has already fallen is a worse outcome than the spray quietly failing because legacy auth was blocked and MFA was mandatory. Build the queries, schedule the correlation rule, and turn on the Identity Protection policies — but spend the larger share of your effort on the Conditional Access posture that makes a correct password insufficient in the first place. That is the part Microsoft should arguably enforce harder by default, and on older tenants it still does not. But here we are.

Related Posts

Editorial note: posts on this blog are drafted with AI assistance and then reviewed, edited, and tested against a real environment before publishing. Commands, output, and screenshots come from systems I actually ran the work on.

Conditional Access Entra ID KQL Microsoft 365 Security PowerShell Threat Hunting
SecurityScriptographer author

About the author

SecurityScriptographer is written and maintained by one person — a defender who builds and tests the detections, scripts, and Microsoft 365 workflows here before publishing them. More about me · @twi_nox

0 comments:

Post a Comment