In this section
TH4.3 Hunting Credential Stuffing
The previous section covered hunting aitm session hijacking. This section covers hunting credential stuffing.
The breach credential economy
Credential stuffing is a volume game. The attacker purchases a database of email-password pairs from a previous breach (LinkedIn 2012, Collection #1-5, various corporate breaches available on dark web markets). They feed these pairs into an automated tool that submits them against your organization's Entra ID login endpoint. If any pair matches, because the user reused the password from the breached service: the attacker gains access without triggering MFA bypass techniques, without phishing, without any social engineering.
Anti-Pattern
Hunting hunting credential stuffing without a hypothesis
The hunter opens Advanced Hunting and starts writing queries without a clear hypothesis. They find interesting data but cannot determine whether it represents a threat, a misconfiguration, or normal activity. Every hunt starts with a hypothesis: a specific, testable statement about attacker behavior. Without a hypothesis, you are exploring, not hunting. Exploration has value, but it produces findings you cannot action without additional scoping.
The detection challenge: each account receives only one or two attempts with a specific password. There is no brute force: no 100 failed attempts against one account. The failures are distributed across hundreds of accounts from dozens of IPs (the attacker uses rotating proxies). The individual events look like normal mistyped passwords. The pattern is only visible in aggregate.
Understanding Entra ID error codes
Entra ID returns specific error codes that distinguish credential stuffing from other login failures. The most relevant:
50126. Invalid username or password. The password submitted does not match the account's current password. This is the primary credential stuffing indicator when it occurs across many accounts in a short window. Note: this code also fires for every legitimate mistyped password, so volume and distribution are required to distinguish attack from noise.
50053. Account locked. The account reached the lockout threshold. In a credential stuffing campaign, lockouts indicate the attacker tried multiple passwords for the same account, crossing from stuffing into brute force territory.
50057. Disabled account. The target account is disabled. Credential stuffing tools do not check account status before attempting, they submit every pair in the breach database. Failures against disabled accounts are strong stuffing indicators because legitimate users know their account is disabled and do not try to log in.
50076. MFA required. The password was correct but MFA challenge was triggered. This is a partial success for the attacker: the password matched. If this occurs for many accounts simultaneously, the breach database contains valid passwords for your users. Even though MFA blocked access, the password match confirms the users are reusing breached passwords.
The credential stuffing detection query
// Credential Stuffing Hunt — Distributed breach-credential attempts
let detectionWindow = 24h;
let minAccounts = 10; // Minimum accounts targeted in the window
let failureCodes = dynamic(["50126", "50057", "50053"]);
SigninLogs
| where TimeGenerated > ago(detectionWindow)
| where ResultType in (failureCodes)
| extend ErrorCode = ResultType
| extend Country = tostring(LocationDetails.countryOrRegion)
| extend City = tostring(LocationDetails.city)
| extend ISP = tostring(
parse_json(LocationDetails).["networkNames"])
// Group by time window and IP to find campaigns
| summarize
TargetedAccounts = dcount(UserPrincipalName),
Accounts = make_set(UserPrincipalName, 50),
FailureCount = count(),
ErrorCodes = make_set(ErrorCode),
Countries = make_set(Country, 10),
TimeSpan = datetime_diff(
'minute', max(TimeGenerated), min(TimeGenerated))
by IPAddress, bin(TimeGenerated, 1h)
| where TargetedAccounts >= minAccounts
// An IP targeting 10+ unique accounts in 1 hour with password
// failures is highly suspicious
// Legitimate users do not fail against 10 different accounts
// from the same IP
| extend AttackRate = round(
todouble(FailureCount) / todouble(TimeSpan + 1), 2)
| sort by TargetedAccounts desc
// INTERPRETATION:
// TargetedAccounts = breadth of the campaign
// AttackRate = speed (failures per minute)
// High rate + high breadth = automated credential stuffing
// Low rate + high breadth = slow-and-slow stuffing (harder to detect)
// ErrorCodes containing "50076" = some passwords matched (critical)
Identifying slow-and-slow attacks
Sophisticated attackers throttle their credential stuffing to stay under rate limits and detection thresholds. Instead of 100 attempts per minute from one IP, they submit 1 attempt per minute from 50 different IPs.
// Slow-and-slow credential stuffing — distributed across many IPs
let detectionWindow = 24h;
let failureCodes = dynamic(["50126", "50057"]);
SigninLogs
| where TimeGenerated > ago(detectionWindow)
| where ResultType in (failureCodes)
| summarize
SourceIPs = dcount(IPAddress),
FailureCount = count(),
AttackerIPs = make_set(IPAddress, 100),
ErrorCodes = make_set(ResultType),
TimeSpan = datetime_diff(
'minute', max(TimeGenerated), min(TimeGenerated))
by UserPrincipalName
| where SourceIPs >= 5 and FailureCount >= 5
// 5+ different IPs all failing for the same user = targeted
// credential rotation (different IPs trying different passwords)
// OR: a user who rotates VPNs — check against the baseline
| sort by SourceIPs desc
// Cross-reference with the TH4.1 baseline:
// If these IPs are NOT in the user's baseline, the multi-IP
// failure pattern is an attack, not a user on multiple networks
Detecting successful credential reuse (the 50076 signal)
The most critical finding in a credential stuffing hunt is not the failures, it is the successes. Error code 50076 (MFA required) means the password matched but MFA blocked access. This confirms the user is reusing a breached password.
// Find accounts where the password MATCHED during the campaign
// These users are reusing breached passwords — immediate risk
let stuffingWindow = 24h;
let stuffingIPs = SigninLogs
| where TimeGenerated > ago(stuffingWindow)
| where ResultType == "50126"
| summarize TargetCount = dcount(UserPrincipalName) by IPAddress
| where TargetCount >= 10
| project IPAddress;
// Now find successes or MFA-blocked attempts from those IPs
SigninLogs
| where TimeGenerated > ago(stuffingWindow)
| where IPAddress in (stuffingIPs)
| where ResultType == "50076" // Password correct, MFA required
or ResultType == "0" // Full success (no MFA or MFA passed)
| project TimeGenerated, UserPrincipalName, IPAddress,
ResultType,
ResultDescription = case(
ResultType == "50076", "PASSWORD MATCHED — MFA blocked",
ResultType == "0", "FULL ACCESS — compromised",
"Other"),
AppDisplayName, ConditionalAccessStatus
| sort by ResultType asc // Full successes first
// ResultType == "0" from a stuffing IP = ACTIVE COMPROMISE
// Immediate escalation to IR
// ResultType == "50076" = password reuse confirmed
// Force password reset for these users immediately
// MFA prevented access this time — it may not next time
// (AiTM can capture the MFA session)
Figure TH4.3. Credential stuffing detection. The failures identify the campaign. The 50076 events identify the users at risk.
Run the primary credential stuffing query with a 24-hour window. Do any IPs target 10+ accounts? If yes, examine the error code distribution, are there any 50076 events (password matched)?
If you find 50076 events, these users are reusing breached passwords. Even though MFA blocked access, the password match means the user's credentials are in an active breach database. Remediation: force password reset, verify MFA method is phishing-resistant, and check whether those users were subsequently targeted by AiTM (run TH4.2 for those specific users).
Run the slow-and-slow query. Do any users have 5+ distinct IPs all failing in 24 hours? Cross-reference with the TH4.1 baseline, if these IPs are not in the user's normal set, the pattern is an attack.
Compliance Context
Password complexity policies govern the format of the password, not its uniqueness. A user who creates `C0mplex!Pass2024` for your organization and also uses `C0mplex!Pass2024` on LinkedIn is fully compliant with your policy and fully vulnerable to credential stuffing. Password complexity is irrelevant if the same password is reused across services. The only defenses are: (1) MFA on every account (blocks access even when the password matches), (2) phishing-resistant MFA (blocks AiTM follow-up after the password match is discovered), (3) Entra ID Password Protection with custom banned words (prevents common patterns), and (4) hunting for 50076 events to identify and remediate users who are currently reusing breached passwords.
Extend this hunt
If your organization uses Microsoft Entra Password Protection (which checks passwords against a global banned list and custom banned words at password change time), the 50076 results are even more concerning: the user set a password that passed your complexity requirements AND the banned list, but it was still in a breach database. This means the breach occurred after the password was set, or the breached service had identical complexity requirements. Either way, the password is compromised. For organizations with Entra ID P2, the "leaked credentials" risk detection in Identity Protection may also flag these users, cross-reference your hunting findings with the Identity Protection risky users report to identify gaps where the hunt caught something Identity Protection missed.
References Used in This Subsection
- Microsoft. "Entra ID sign-in error codes." Microsoft Learn. https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sign-in-error-codes
- Course cross-references: TH4.1 (baseline for IP cross-reference), TH4.4 (password spray, related but distinct pattern)