>
av.Amit VijayanCYBERSECURITY & RESEARCHLet\u2019s connect
Back to the library

Threat Hunting Playbooks

Hunting Password Spraying Against Entra ID with Smart-Lockout Telemetry

Thumbnail for: Hunting Password Spraying Against Entra ID with Smart-Lockout Telemetry

Why this matters now

Password spraying is still the cheapest way into a Microsoft 365 tenant — no exploit, no malware, just one common password tried against thousands of accounts. Entra ID's smart lockout was built to raise the cost: hammer one account with bad passwords and it locks, first for a minute, then longer with every new failure. But smart lockout is per-account, not per-source, and attackers know it. A spray that tries one password per account across hundreds of accounts never crosses any account's lockout threshold. Nothing locks. Nothing alerts. The only residue is a quiet tide of bad-password errors — AADSTS 50126 — spread thin enough that per-account alerting never fires.

This is why password spraying is a hunter's problem, not a rule's problem. The detection pattern is not "many failures" — it is many distinct accounts, few attempts each, one source. And the tell lives entirely in the Entra sign-in log, which most tenants collect but few query. This playbook hunts the spray, reads the smart-lockout fallout, and catches the one attempt that actually worked.

How the spray stays under the radar

1. Spread the attempts, dodge the lockout

Smart lockout counts bad passwords per account. Try roughly ten wrong passwords against one account inside a minute and Entra locks it — error 50053 — which is both a denial of service for the real user and a loud signal for the SOC. Try one password against ten thousand accounts and every account stays comfortably under its threshold. The attacker trades depth for breadth: instead of guessing one person's password a hundred times, they guess one password — the tenant's most likely password — a hundred thousand times. Usernames are cheap: they come from prior breaches, public scraping, and tenant enumeration through legitimate endpoints. The only thing that changes on the defender's side is the total volume of 50126 errors, which looks like background noise unless you group it by source.

2. Let smart lockout confirm the valid accounts

Here is the twist attackers count on: smart lockout itself is an oracle. Error 50034 means the user does not exist; 50126 means the user exists but the password was wrong; 50053 means the account locked. Each code tells the attacker which usernames are real. A spray run therefore doubles as username validation — the 50034s get discarded, the 50126s go on the list for next time. When you hunt, that same distinction is yours to use: an IP returning mostly 50034s is spraying guesses; an IP returning mostly 50126s is spraying confirmed accounts. And a sudden burst of 50053 lockouts across many accounts is the moment the spray got greedy — or a different attacker is hammering the accounts your spray just validated.

3. Rotate infrastructure, keep the passwords common

Modern sprays rarely come from one datacenter IP anymore. They rotate through residential proxy networks, so each IP attempts only a slice of the campaign — but each slice still shows the signature: dozens of distinct accounts, a handful of failures each, the same error code. Some campaigns go lower and slower, one attempt per account per day, which defeats any rule with a short window. That is why the first hunt below runs on a 24-hour window and flags the account-breadth ratio, not the raw attempt count: a source that touches 50 accounts with 60 total failures is far more suspicious than one that tries 500 passwords against a single account.

The KQL hunts

These queries target AADSignInEventsBeta in Microsoft Defender XDR advanced hunting. If you ship Entra sign-in logs to Sentinel instead, the same logic applies to SigninLogs (swap AccountUpn for UserPrincipalName and Timestamp for TimeGenerated).

Hunt 1 — One source, many accounts: the classic spray signature

The defining shape of a password spray: one source IP producing bad-password errors across many distinct accounts, with only a few attempts per account. Tune the thresholds to your tenant's size — ten distinct accounts is a reasonable floor:

// Hunt: spray signature — one IP, many distinct accounts, bad-password errors
let FailedCodes = dynamic([50126, 50053]); // bad password, smart-lockout lock
AADSignInEventsBeta
| where Timestamp > ago(24h)
| where ErrorCode in (FailedCodes)
| where isnotempty(IPAddress) and isnotempty(AccountUpn)
| summarize FailedAttempts = count(),
    TargetedAccounts = dcount(AccountUpn),
    UserAgents = make_set(UserAgent, 5),
    Applications = make_set(Application, 5),
    FirstSeen = min(Timestamp),
    LastSeen = max(Timestamp)
    by IPAddress
| extend AttemptsPerAccount = toreal(FailedAttempts) / toreal(TargetedAccounts)
| where TargetedAccounts >= 10 and AttemptsPerAccount <= 5
| project IPAddress, TargetedAccounts, FailedAttempts, AttemptsPerAccount,
    FirstSeen, LastSeen, UserAgents, Applications
| order by TargetedAccounts desc

The AttemptsPerAccount <= 5 filter is the point: it keeps the spray-shaped sources and drops brute-force noise. Exclude your own vulnerability scanners, password-audit tooling, and SSO health checks by IP before alerting — they are the legitimate lookalikes for this pattern.

Hunt 2 — Smart-lockout fallout: the accounts attackers validated

Error 50053 only fires for accounts that exist. A cluster of lockouts across the tenant is a map of the accounts the attacker now knows are real — protect those accounts first:

// Hunt: smart-lockout fallout — which accounts locked, and from where
AADSignInEventsBeta
| where Timestamp > ago(7d)
| where ErrorCode == 50053
| where isnotempty(AccountUpn)
| summarize Lockouts = count(),
    SourceIPs = dcount(IPAddress),
    SourceIPList = make_set(IPAddress, 20),
    FirstSeen = min(Timestamp),
    LastSeen = max(Timestamp)
    by AccountUpn
| where Lockouts >= 3
| project AccountUpn, Lockouts, SourceIPs, FirstSeen, LastSeen, SourceIPList
| order by Lockouts desc

Read the shape: one IP locking many accounts is the spray from Hunt 1 turning greedy — pivot back to that IP. Many IPs locking one account is targeted brute force or a distributed spray, and that account needs a password reset and an MFA-method review now. Cross-check the locked accounts against your privileged and service accounts; attackers prioritize admin-looking usernames.

Hunt 3 — The success that should not exist: a cracked password from a spray source

A spray only needs one hit. Any source that generated spray-level failures and then produced even one successful sign-in (error code 0) has likely cracked a password — treat it as an active compromise until proven otherwise:

// Hunt: spray source that eventually succeeded — likely cracked credential
let SpraySources = AADSignInEventsBeta
| where Timestamp > ago(24h)
| where ErrorCode in (50126, 50053)
| where isnotempty(IPAddress)
| summarize FailedAttempts = count(), TargetedAccounts = dcount(AccountUpn)
    by IPAddress
| where FailedAttempts >= 20 and TargetedAccounts >= 10
| project IPAddress;
AADSignInEventsBeta
| where Timestamp > ago(24h)
| where IPAddress in (SpraySources)
| where ErrorCode == 0
| summarize Successes = count(),
    Accounts = make_set(AccountUpn, 10),
    FirstSuccess = min(Timestamp),
    LastSuccess = max(Timestamp)
    by IPAddress
| project IPAddress, Successes, Accounts, FirstSuccess, LastSuccess
| order by FirstSuccess desc

Do not dismiss a single success. One valid session from a spray source is the whole payoff of the campaign — start incident response, revoke the sessions for the listed accounts, and check what each account touched after FirstSuccess.

Splunk equivalents

These assume Entra sign-in logs ingested via the Splunk Add-on for Microsoft Cloud Services (sourcetype="azure:aad:signin"). The same field logic works against any index carrying the Entra sign-in schema.

SPL 1 — One source, many accounts: the spray signature

sourcetype="azure:aad:signin" earliest=-24h
('properties.status.errorCode'=50126 OR 'properties.status.errorCode'=50053)
| stats count AS failed, dc('properties.userPrincipalName') AS accounts,
    values('properties.userAgent') AS agents,
    earliest(_time) AS first_seen, latest(_time) AS last_seen
    BY 'properties.ipAddress'
| eval attempts_per_account=round(failed/accounts,2)
| where accounts>=10 AND attempts_per_account<=5
| sort - accounts

SPL 2 — Smart-lockout fallout per account

sourcetype="azure:aad:signin" earliest=-7d 'properties.status.errorCode'=50053
| stats count AS lockouts, dc('properties.ipAddress') AS src_ips,
    values('properties.ipAddress') AS src_ip_list,
    earliest(_time) AS first_seen, latest(_time) AS last_seen
    BY 'properties.userPrincipalName'
| where lockouts>=3
| sort - lockouts

SPL 3 — Success after failure from a spray source

sourcetype="azure:aad:signin" earliest=-24h
| eventstats sum(eval(if('properties.status.errorCode'=50126
    OR 'properties.status.errorCode'=50053,1,0))) AS ip_failed
    BY 'properties.ipAddress'
| where ip_failed>=20 AND 'properties.status.errorCode'=0
| stats count AS successes, values('properties.userPrincipalName') AS accounts,
    earliest(_time) AS first_success, latest(_time) AS last_success
    BY 'properties.ipAddress'
| sort first_success

Triage: what to do when a hunt hits

  • Confirm the source is not yours. Vulnerability scanners, your own red team's password-spray audits, and SSO monitoring all produce this shape. Check the IP against your asset list and change calendar before anything else.
  • Score the error mix. Mostly 50034 (user does not exist) means the spray is working off a guessed or stale username list — low immediate risk, but note the IP. Mostly 50126 means the attacker holds confirmed-valid usernames — the risk is real even if nothing succeeded. 50053 bursts mean the campaign turned noisy: pull those accounts' full sign-in history.
  • Check for the near-miss that matters most. Codes like 50076 (MFA required) or 53003 (Conditional Access blocked) are only returned after the password validated — a spray source producing those has a correct password and was stopped by policy. Treat those accounts as compromised-in-waiting: reset the password and review MFA methods immediately.
  • Trace what a successful session touched. For any Hunt 3 hit, pivot on the account: list app access, mailbox rule changes, and OAuth consent grants in the hour after first success. A spray win usually escalates to persistence within the hour.
  • Enrich the IP. ASN, hosting provider, and first-seen-in-tenant give you the campaign's shape. Residential-proxy and anonymizer ASN space confirms the distributed-spray hypothesis; a single cloud-hosted IP suggests an opportunistic tool run.

Remediation and hardening

  • Reset passwords on every account that showed a near-miss. Any account behind a 50076 or 53003 from an unknown source had its password guessed correctly — policy stopped this attempt, not the next one.
  • Enforce phishing-resistant MFA on the validated accounts. The lockout-fallout accounts from Hunt 2 are the attacker's shortlist. Move them to authenticator with number matching or FIDO2, and review their registered MFA methods for attacker-added phone numbers.
  • Block the spray infrastructure at the identity layer. Named locations plus Conditional Access sign-in-risk policies can require MFA or block legacy-auth attempts from the spray's ASN and country space. Block legacy authentication protocols tenant-wide — sprays love IMAP, POP, and SMTP AUTH because they bypass modern MFA prompts.
  • Shorten the feedback loop with Entra ID Protection. The built-in "Password spray" risk detection already watches for this pattern; wire its medium-and-above risk events into your SOC queue so the platform's signal backs up the hunts above.
  • Reduce the username supply. Usernames come from breaches and scraping. You cannot stop scraping, but you can stop the oracle: make sure external-facing error responses and user-enumeration endpoints do not leak which accounts exist, and treat any service account with a predictable name as a priority hardening target.
  • Alert on breadth, not just volume. Turn Hunt 1 into a scheduled rule: distinct-account counts per source over a rolling 24-hour window beat any fixed "N failures" threshold, because the whole point of a spray is to keep failures per account low.

Mapping to MITRE ATT&CK

For detection-engineering bookkeeping, the password-spray hunt maps to: T1110.003 (Brute Force: Password Spraying — the core technique), T1078 (Valid Accounts — operating on the one password that worked), T1110.001 (Brute Force: Password Guessing — the targeted follow-up on validated accounts), T1556 (Modify Authentication Process — if the attacker registers their own MFA method after a spray win), and T1078.004 (Valid Accounts: Cloud Accounts — the Entra session the attacker ends up holding).

The pattern generalizes: any per-entity rate limit can be beaten by spreading attempts across entities. Spraying is the identity-layer instance of that principle — the same breadth-over-depth logic shows up in API abuse, OTP guessing, and voucher fraud. Hunt the breadth.

Keep exploring.

All articles