Skip to content

How to detect password spraying in Microsoft Entra ID (with KQL)

Password spraying shows up in Entra ID sign-in logs as one source failing the password check for many different users, with only a few attempts per user. Look for ResultType 50126, 50053, 50057 and 50034 grouped by IP. Then check whether the same IP ever got past the password. That second step decides how bad it is.

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

Section titled “What does password spraying look like in the sign-in logs?”

A spray tries a small number of common passwords against a long list of accounts. Brute force does the opposite: many passwords against one account. In the logs the difference is the shape of the failures, not the error code. Both produce mostly 50126.

These are the codes you will see from a spraying source, and what each one tells you.

Code Microsoft’s name What it tells you during a spray
50126 InvalidUserNameOrPassword Wrong password. The bulk of a spray. Microsoft says to “expect to see some number of these errors in your logs due to users making mistakes”, so the signal is volume per source, not presence.
50053 IdsLocked Either smart lockout locked the account, or built-in protections blocked the sign-in from a malicious IP. Two mechanisms, one code. See the smart lockout section below.
50057 UserDisabled The account is disabled. Attempts against leavers and other disabled accounts suggest the attacker is working from an old user list.
50034 UserAccountNotFound The username doesn’t exist in your tenant. Many of these from one IP with varying usernames is someone testing a guessed user list.
50055 InvalidPasswordExpiredPassword The password is expired. Microsoft’s description implies the password itself was correct. That is my reading of the wording, not a Microsoft statement. Treat it as a valid credential in attacker hands.
53003 BlockedByConditionalAccess Conditional Access blocked the sign-in. CA evaluates after the first factor succeeds, so this is a correct password stopped by policy.
50074, 50076, 50079 MFA interrupts The password was accepted and MFA, or MFA registration, is now the only barrier.

The last three rows matter most. Microsoft’s password spray playbook lists 50057, 50055, 50072, 50074, 50079, 53003, 53004 and 530032 as the relevant Entra codes. It does not list 50126 or 50053. Those come from how the codes are defined, and from how detection content is commonly written, not from the playbook.

Two column details trip people up when reading spray rows:

  • ConditionalAccessStatus is notApplied on password failures. A Microsoft moderator on Microsoft Q&A states that “Conditional access policies only apply after the first factor of authentication is successfully completed.” A 50126 row with notApplied doesn’t mean you have no policies.
  • AuthenticationRequirement shows singleFactorAuthentication on password failures, even in an MFA-enforced tenant. Microsoft documents this directly: “For sign-in attempts where MFA is required but primary authentication failed, value is singleFactorAuthentication because the attempt wasn’t evaluated by Conditional Access to require MFA.” Don’t read it as “MFA is off”.

Why does legacy authentication matter for password spraying?

Section titled “Why does legacy authentication matter for password spraying?”

Legacy protocols such as IMAP, POP, SMTP AUTH and Exchange ActiveSync send a username and password and cannot answer an MFA challenge. If a tenant still accepts them, a sprayed password can be enough on its own. That is why Microsoft’s playbook tells you to filter sign-ins on Client App and select all legacy protocols.

The column is ClientAppUsed. Microsoft’s schema describes it as “The legacy client used for sign-in activity. For example: Browser, Exchange ActiveSync, Modern clients, IMAP, MAPI, SMTP, or POP.” The exact strings in your workspace may differ from that example list. Run this once to see the real values before you filter on them:

union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(7d)
| summarize Rows = count(), NonZeroResults = countif(ResultType != "0")
by Type, ClientAppUsed
| sort by Rows desc

The two modern values I expect to see are Browser and Mobile Apps and Desktop clients. That is from experience, not from the schema page, so confirm it against the output above. The queries below treat any other value as legacy.

Tenants on Security Defaults may see 530035 for legacy sign-ins (it is covered as a sibling on the 530032 page). Microsoft’s description: “Access has been blocked by security defaults. This is due to the request using legacy auth or being deemed unsafe by security defaults policies.”

How does smart lockout affect what you see?

Section titled “How does smart lockout affect what you see?”

Smart lockout is always on. Microsoft documents the defaults:

  • “By default, smart lockout locks an account from sign-in after: 10 failed attempts in Azure Public and Microsoft Azure operated by 21Vianet tenants; Three failed attempts for Azure US Government tenants.”
  • “The account locks again after each subsequent failed sign-in attempt. The lockout period is one minute at first, and longer in subsequent attempts.”
  • “Both unfamiliar and familiar locations have separate lockout counters.”

Changing the threshold or duration needs Entra ID P1. Once locked, the user sees 50053. Microsoft notes that self-service password reset (“I forgot my password”) resets the lockout timer.

Three things follow for detection. These are my reasoning from the documented behaviour:

  1. No 50053 does not mean no spray. A spray that tries fewer than 10 passwords per account per lockout window never trips the counter. Most of what you will see is 50126.
  2. A lockout from an attacker’s IP should not lock out the real user at their usual location, because the counters are separate. If users at the office are getting 50053 during a spray, check whether the office IP is the source.
  3. A burst of 50053 across many accounts from one IP is a spray that tripped lockout. Count distinct users, not rows.

There is a second, separate source of 50053. Microsoft says Entra ID “blocks these malicious sign-ins by default and returns AADSTS50053 - IdsLocked error code, regardless of the password validity.” ID Protection’s automatic block uses the same code, and the sign-in log shows the failure reason “Sign-in was blocked by built-in protections due to high confidence of risk.” Microsoft says this “most commonly occurs on sign-ins performed using legacy authentication protocols or displaying properties of a malicious attempt”. Because the block happens regardless of password validity, a 50053 of this kind tells you nothing about whether the password was right. The 50053 page has a query that splits the two mechanisms.

How do you investigate a password spray step by step?

Section titled “How do you investigate a password spray step by step?”

The four queries below build on each other. Run them in order. All of them compare ResultType as a string ("50126", not 50126), because the column is a string in both sign-in tables.

The thresholds (10 users, 3 attempts per user) are starting points I chose, not Microsoft numbers. Tune them against your own baseline. The queries also include 50056, “Invalid or null password”, which is another password-stage failure and is counted with 50126.

Step 1: Which sources are failing passwords for many users?

Section titled “Step 1: Which sources are failing passwords for many users?”
let lookback = 1d;
let modernClients = dynamic(["Browser", "Mobile Apps and Desktop clients"]);
SigninLogs
| where TimeGenerated > ago(lookback)
| where ResultType in ("50126", "50053", "50057", "50034", "50056")
| summarize Attempts = count(),
Users = dcount(UserPrincipalName),
LegacyAttempts = countif(ClientAppUsed !in (modernClients)),
Codes = make_set(ResultType, 10),
Clients = make_set(ClientAppUsed, 10),
Apps = make_set(AppDisplayName, 10),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by IPAddress
| where Users >= 10
| extend AttemptsPerUser = round(todouble(Attempts) / Users, 1)
| extend Pattern = iff(AttemptsPerUser <= 3, "Spray: many users, few tries each", "Mixed or brute force")
| sort by Users desc

What to read in the result:

  • Users high, AttemptsPerUser low: classic spray.
  • LegacyAttempts close to Attempts: the tool is using legacy protocols.
  • Codes contains 50034 or 50057: the attacker’s user list includes names that don’t exist or accounts you disabled. That points at an old or guessed list.

Sprays from many IPs at once (residential proxies, cloud functions) will not cross the Users >= 10 line for any single IP. To catch those, look at the tenant-wide rate of failing users per hour and compare it with a normal week:

SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == "50126"
| summarize FailingUsers = dcount(UserPrincipalName), SourceIPs = dcount(IPAddress)
by bin(TimeGenerated, 1h)
| render timechart

A spike in FailingUsers with a matching spike in SourceIPs is a distributed spray. From there, pivot on shared UserAgent or AppDisplayName values instead of IP.

If you still allow legacy authentication, run Step 1 against AADNonInteractiveUserSignInLogs too. Whether legacy-auth sign-ins land in that table in your tenant is something to check, not assume.

Step 2: Did any spraying source get past the password?

Section titled “Step 2: Did any spraying source get past the password?”

This is the question that matters. Microsoft’s playbook says: “Ensure you record successful passwords but failed MFA responses, as this activity indicates that the attacker is getting the password but not passing MFA.” It also sets the order to work in: successful sign-ins from the attacker IP first, then interrupted ones, then failures.

The query takes the sources from Step 1 and labels every later row by how far it got.

let lookback = 1d;
let sprayIPs =
SigninLogs
| where TimeGenerated > ago(lookback)
| where ResultType in ("50126", "50053", "50057", "50034", "50056")
| summarize Users = dcount(UserPrincipalName) by IPAddress
| where Users >= 10
| project IPAddress;
union
(SigninLogs
| project TimeGenerated, UserPrincipalName, IPAddress, ResultType, AppDisplayName,
ClientAppUsed, Table = "Interactive"),
(AADNonInteractiveUserSignInLogs
| project TimeGenerated, UserPrincipalName, IPAddress, ResultType, AppDisplayName,
ClientAppUsed, Table = "NonInteractive")
| where TimeGenerated > ago(lookback)
| where IPAddress in (sprayIPs)
| extend Stage = case(
ResultType in ("0", "50140"),
"1 - Signed in",
ResultType in ("50074", "50076", "50072", "50079", "500121", "50158", "50125"),
"2 - Password accepted, stopped at MFA or registration",
ResultType in ("53003", "53000", "53001"),
"2 - Password accepted, blocked by Conditional Access",
ResultType == "50055",
"2 - Password correct but expired",
"3 - Failed at password or blocked")
| where Stage !startswith "3"
| summarize FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated),
Codes = make_set(ResultType, 10),
Apps = make_set(AppDisplayName, 10),
Clients = make_set(ClientAppUsed, 10)
by Stage, UserPrincipalName, IPAddress, Table
| sort by Stage asc, FirstSeen asc

How the stages are assigned, and how sure each one is:

  • 0 is a successful sign-in. Documented.
  • 50140 is the “Keep me signed in” prompt. Microsoft calls it “an expected part of the sign in flow”. It is the last prompt, so it almost certainly means the password and any MFA were accepted. That is my reading. I count it as signed in.
  • MFA and registration interrupts (50074, 50076, 50072, 50079, 500121, 50158, 50125) only happen after the password stage. The password was right.
  • 53003, 53000, 53001 are Conditional Access results. CA runs after the first factor, so the password was right.
  • 530032 is deliberately left out of stage 2. Microsoft does not say which tenant-level policy produces it or whether the first factor had succeeded. Look at those rows by hand.

Every user in stage 1 or stage 2 has a password the attacker knows. Stage 1 users are compromised.

Step 3: What did the attacker do with a compromised account?

Section titled “Step 3: What did the attacker do with a compromised account?”

Take one user from Step 2 and build a timeline across both sign-in tables. LocationDetails and DeviceDetail are dynamic in SigninLogs and strings in AADNonInteractiveUserSignInLogs, so each leg extracts its fields before the union.

let suspectUser = "[email protected]";
let lookback = 7d;
union
(SigninLogs
| where TimeGenerated > ago(lookback)
| where UserPrincipalName =~ suspectUser
| extend Country = tostring(LocationDetails.countryOrRegion),
OS = tostring(DeviceDetail.operatingSystem),
Table = "Interactive"),
(AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(lookback)
| where UserPrincipalName =~ suspectUser
| extend Country = tostring(parse_json(LocationDetails).countryOrRegion),
OS = tostring(parse_json(DeviceDetail).operatingSystem),
Table = "NonInteractive")
| project TimeGenerated, Table, IPAddress, Country, OS, ResultType, ResultDescription,
AppDisplayName, ResourceDisplayName, ClientAppUsed, AuthenticationRequirement,
ConditionalAccessStatus, UserAgent
| sort by TimeGenerated asc

What to look for:

  • The first 0 from an IP the user doesn’t normally use. That is the start of the attacker’s session.
  • Non-interactive rows from the same IP after that point. Those are refresh tokens being used, which means the session persisted.
  • Which apps and resources appear. Mail, files and the Graph API tell you where to look next.

Non-interactive rows are aggregated when application, user, IP address, status and resource match. A single row can stand for many refreshes, so don’t read row counts as request counts.

The JSON field names (countryOrRegion, operatingSystem) follow the Microsoft Graph signIn resource. Check them against one row in your own workspace.

Step 4: After you respond, did revocation actually cut the sessions?

Section titled “Step 4: After you respond, did revocation actually cut the sessions?”

Run this after the response steps below. After a password reset or session revocation, any client still holding the old refresh token fails with 50173 on its next refresh. Microsoft lists the causes as “The user changes or resets their password. The refresh token expires. An administrator revokes the refresh token.”

let revokedUser = "[email protected]";
let revokedAt = datetime(2026-10-11T09:00:00Z);
AADNonInteractiveUserSignInLogs
| where TimeGenerated > revokedAt
| where UserPrincipalName =~ revokedUser
| where ResultType in ("50173", "0")
| extend Location = parse_json(LocationDetails)
| summarize FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated),
Apps = make_set(AppDisplayName, 10)
by ResultType, IPAddress, Country = tostring(Location.countryOrRegion), UserAgent
| sort by FirstSeen asc

The query keeps 0 rows as well as 50173, so that the third case below is visible in the same output. Successful rows from the user’s own devices are expected: they re-authenticated.

  • 50173 from the attacker’s IP: the attacker’s session was cut off. Good.
  • 50173 only from the user’s own devices: revocation worked, and the attacker either had no live session or hasn’t tried again yet.
  • 0 rows from the attacker’s IP after revokedAt: the attacker signed in again. They still have a working password or another way in. Go back to the response steps.

How should you respond to a confirmed password spray?

Section titled “How should you respond to a confirmed password spray?”

For every account in stage 1 or stage 2 of Step 2:

  1. Reset the password. The attacker knows it. For stage 2 accounts, the only thing that held was MFA or Conditional Access.

  2. Revoke sessions. In the Entra admin center, open the user, then select Revoke sessions. Or use PowerShell:

    Terminal window
    Revoke-MgUserSignInSession -UserId $User.Id

    The Graph documentation for this call says it “Invalidates all the refresh tokens issued to applications for a user (and session cookies in a user’s browser)” and that “there might be a small delay of a few minutes before tokens are revoked.” Microsoft’s revoke-access procedure (disable, reset, revoke, disable devices) says that after those steps “the user can’t gain new tokens for any application tied to Microsoft Entra ID.” Revocation on its own does not stop someone who still knows the password from signing in again, which is why the reset comes first. Access tokens already issued are not withdrawn by this: “By default, access tokens issued by Microsoft Entra ID last for 1 hour.” Continuous Access Evaluation enforces an admin revocation on CAE-capable clients and services (Exchange Online, SharePoint Online, Teams) faster, with “latency of up to 15 minutes” per Microsoft. The session revocation guide has the detail.

  3. Verify with Step 4. Look for 50173 from the attacker’s IP.

  4. Check MFA registration for the account. If the account was in stage 2 with 50079, the attacker may have been offered MFA registration. Look for security info registered from the attacker’s IP. The 50079 page has the AuditLogs query.

For the tenant:

  1. Block legacy authentication with Conditional Access. Run the ClientAppUsed inventory query above first, filtered to ResultType == "0", so you know which users and systems still sign in that way and will break.
  2. Require MFA through Conditional Access for all users, so that a sprayed password alone does not produce a session. Phishing-resistant methods are the stronger option.
  3. Review smart lockout settings if you have P1. The defaults are documented above.
  4. Consider a custom banned password list. Spray lists commonly include seasonal and company-name passwords, so banning those for your organisation is cheap protection.
  5. Look at disabled and non-existent targets. If the spray hit 50057 accounts, your user list has leaked or been guessed from old data. That is worth knowing even though those attempts failed.
  • Ran Step 1 and listed every IP failing passwords for 10 or more users.
  • Checked the hourly failing-user chart for a distributed spray that per-IP counts miss.
  • Ran Step 2 and listed every user with a 0, 50140, MFA interrupt, CA block or 50055 from a spraying IP.
  • Treated every stage 1 and stage 2 user as having a known password.
  • Built a timeline (Step 3) for each stage 1 user.
  • Reset passwords and revoked sessions for stage 1 and stage 2 users.
  • Confirmed 50173 from the attacker’s IP, or no attacker activity, after revocation (Step 4).
  • Checked AuditLogs for security info registered by the attacker on any 50079 account.
  • Checked whether the spray used legacy clients, and planned the legacy-auth block.
  • Remembered that ConditionalAccessStatus = notApplied and singleFactorAuthentication on password failures are normal, not a policy gap.

50126 · 50053 · 50057 · 50055 · 50034 · 53003 · 530032 · 50074 · 50076 · 50079 · 50140 · 50173

Sources

  1. Password spray investigation (Microsoft incident response playbook)
  2. Protect user accounts from attacks with Microsoft Entra smart lockout
  3. Microsoft Entra authentication and authorization error codes
  4. Sign-in log activity details
  5. Non-interactive user sign-ins
  6. Remediate risks and unblock users (Microsoft Entra ID Protection)
  7. Revoke user access in Microsoft Entra ID
  8. Troubleshoot AADSTS50173: grant expired or revoked
  9. Microsoft Graph: user: revokeSignInSessions
  10. Continuous access evaluation
  11. SigninLogs table reference (Azure Monitor)
  12. AADNonInteractiveUserSignInLogs table reference (Azure Monitor)
  13. Microsoft Q&A: Clarify what 50053 really means (community thread, Microsoft moderator answer)