Fixing the "Attempts to Sign In to Disabled Accounts" Analytic Rule
Alright class.
Seventh lesson in this series.
Back to the cloud for this one. It runs on the Entra sign-in logs, and it fires on error code 50057, the account is disabled. The premise is sound: someone trying to authenticate as a disabled account is worth a look. The problem is the threshold it uses to decide what counts, because that threshold quietly selects for the one pattern you do not care about.
Three Apps Is the Wrong Gate
The original fires when a disabled account throws 50057 across three or more distinct applications. The logic reads as noise reduction: a single failed sign-in is nothing, but hitting many apps looks like enumeration. In practice it is backwards.
Think about what actually produces 50057 across half a dozen apps. An account gets disabled at offboarding. That account still has live sessions all over the Microsoft 365 suite: Outlook, Teams, SharePoint, OneDrive, the lot. Every one of those clients keeps trying to refresh its token, and every refresh now comes back 50057. That is the multi-app pattern, and it is completely benign. It is the sound of a dead account's sessions winding down. So a three-app threshold does not catch enumeration. It catches the token-refresh storm of a freshly disabled user, which is exactly the thing a SOC should never see a ticket for.
Meanwhile the pattern you do want gets missed. An attacker with a leaked credential for a former employee hammers one app, the login portal, with the right password. The account happens to be disabled, so it returns 50057. One app. Never reaches three. Silently dropped.
What Separates an Attacker From a Dying Session
The app count is the wrong axis. Three things actually tell the two apart.
Interactivity. A token refresh is non-interactive, it lives in AADNonInteractiveUserSignInLogs. A human or a tool actively submitting credentials is interactive, in SigninLogs. The original unions both tables and counts apps across the lot, which buries the small number of active attempts under the large volume of background refreshes. Scope it instead: interactive 50057 is an active attempt and worth surfacing on its own, even once. Non-interactive 50057 is mostly the benign storm, so keep it only when something else corroborates it. It is not dropped entirely, because credential-stuffing through the ROPC flow is non-interactive too, and that is a real attack against a disabled account.
Time since the account was disabled. The refresh storm dies out in hours as tokens expire and continuous access evaluation kicks in. Attempts arriving days or weeks after the account was disabled are not refreshes, they are someone reaching for an account they think still works. So pull the disable event from the audit log and measure the gap. An attempt seven days after disabling is a different conversation from one seven minutes after.
Sign-in risk. If Entra ID Protection scored the attempt medium or high, that rides along in the same row and is worth promoting regardless of app count.
The Rebuild
The rewrite fires on an active or stale or risky attempt, not on app spread:
| where InteractiveCount > 0 or IsConfirmedStale or HighRisk
Interactive attempts fire. Attempts confirmed to be landing well after the account was disabled fire. Risk-flagged attempts fire. App count is kept, but demoted to a risk indicator rather than the gate, because on its own it is the benign signal.
The disable recency comes from a small AuditLogs lookup for the account-disable event. Note the degradation: the interactive and risk paths need no audit data at all, so if AuditLogs is thin the rule still works, it just loses the stale-credential path. Confirmed-stale only fires on positive evidence, so a missing disable event never causes a false alarm.
The RiskIndicators Field
Same accumulating string as the rest of the series, mapped as a custom detail. Seven indicators:
InteractiveAttempt | StaleDisabledAccount | HighSignInRisk | MultiAppEnumeration | PrivilegedFormerAccount | HighVolume | GuestAccount
The combination to chase is InteractiveAttempt with StaleDisabledAccount and PrivilegedFormerAccount: an active sign-in to the disabled account of a former administrator, well after it was switched off. That is someone working a known-good target, not a session cleaning up after itself.
The Blind Spot You Should Know About
The recency signal is only as good as your audit retention and the disable event carrying a UPN. If the account was disabled longer ago than the audit lookback, recency falls back to null and the attempt has to fire on interactivity or risk instead. Some tenants disable accounts through a generic user-update rather than the explicit disable operation, so widen the audit match if yours does.
And the obvious one: this is a failed-attempt detection. 50057 is by definition a sign-in that did not succeed, because the account is off. The far worse event, an account that gets re-enabled and then used, is a different detection on the audit log, and it deserves its own rule.
MITRE Mappings for the Updated Rule
Tactic: Credential Access, with Initial Access for the external attempt.
T1078.004 Valid Accounts, Cloud Accounts. The attempt to authenticate with credentials for a cloud account, here one that happens to be disabled.
T1110.003 Password Spraying. The high-volume path, an attacker spraying a disabled account as part of a wider sweep.
T1087.004 Account Discovery, Cloud Account. The multi-app enumeration indicator covers an actor probing which services an account can still reach.
Rule Settings
Run every 60 minutes with a 14 days lookback query period. The original ran daily. The audit and identity lookbacks are independent and reach back further. Medium severity, raised by the indicators. Alert per result. Group by Account entity with a 6 hour lookback, so a spray from several IPs against one account collapses into a single incident.
Entity mapping:
- Name to Account (Name), UPNSuffix to Account (UPNSuffix)
- IPAddress to IP (Address)
Custom details to surface in the incident: RiskIndicators, AttemptCount, InteractiveCount, AppCount, DaysSinceDisabled, Country, AssignedRoles, UserType.
KQL
// =====================================================================
// Attempts to Sign In to Disabled Accounts - Entra ID
// =====================================================================
// Description : Detects sign-in attempts to disabled accounts (error 50057), prioritised by
// interactivity, time since the account was disabled, and sign-in risk rather
// than by application count. App count is kept only as a risk indicator, because
// on its own it selects for the benign post-disable token-refresh storm.
// Type : Detection
//
// Tables : SigninLogs, AADNonInteractiveUserSignInLogs, AuditLogs, IdentityInfo
// Connectors : Microsoft Entra ID (SignInLogs, NonInteractiveUserSignInLogs, AuditLogs),
// Microsoft Sentinel UEBA (IdentityInfo)
// License : Microsoft Sentinel; Microsoft Entra ID P1 (sign-in / audit logs),
// P2 recommended (sign-in risk, UEBA / IdentityInfo)
//
// Tuning : - DetectionWindow - align to run frequency and sign-in ingestion latency
// - DisableLookback - set to your AuditLogs retention so older disables still resolve
// - StaleDisableDays - days after disabling beyond which attempts are not token refresh
// - AppThreshold / VolumeThreshold - thresholds for the MultiAppEnumeration / HighVolume indicators
// - SensitiveRoles - roles that set the PrivilegedFormerAccount indicator
// - In very noisy tenants, require InteractiveCount >= 2 rather than > 0
//
// Known FPs : - Freshly disabled accounts whose own M365 sessions refresh tokens - non-interactive, recent, suppressed
// - A former user trying to sign in once after offboarding - low priority, no stale/risk indicators
// - Disable performed via a generic user-update operation - widen the AuditLogs match
//
// Author : Bartosz Wysocki | https://www.itprofessor.cloud
// Version : 1.0 | 2026-06-17
// =====================================================================
let DetectionWindow = 1h;
let DisableLookback = 14d; // how far back to find the account-disable event; match your AuditLogs retention
let IdentityLookback = 14d;
let StaleDisableDays = 7; // attempts this many days after disabling are not explained by token refresh
let AppThreshold = 3; // distinct apps for the MultiAppEnumeration indicator (original default)
let VolumeThreshold = 10; // attempts for the HighVolume indicator
let SensitiveRoles = dynamic([
"Global Administrator",
"Privileged Role Administrator",
"Privileged Authentication Administrator",
"Security Administrator",
"Exchange Administrator",
"SharePoint Administrator",
"User Administrator",
"Intune Administrator",
"Application Administrator",
"Hybrid Identity Administrator"
]);
// 50057 attempts from a sign-in table, tagged interactive or not
let DisabledAttempts = (tableName:string, isInteractive:bool) {
table(tableName)
| where ResultType == "50057"
| project TimeGenerated,
UserPrincipalName = tolower(UserPrincipalName),
UserId,
IPAddress,
AppDisplayName,
ClientAppUsed,
Country = tostring(LocationDetails.countryOrRegion),
City = tostring(LocationDetails.city),
RiskLevel = tostring(RiskLevelDuringSignIn),
IsInteractiveFlag = isInteractive,
SourceTable = tableName
};
let Attempts = union isfuzzy=true
DisabledAttempts("SigninLogs", true),
DisabledAttempts("AADNonInteractiveUserSignInLogs", false)
| where TimeGenerated > ago(DetectionWindow);
// When was each targeted account disabled? (Entra audit)
let DisableEvents = AuditLogs
| where TimeGenerated > ago(DisableLookback)
| where OperationName has "Disable account"
| mv-apply tr = TargetResources on (
where isnotempty(tostring(tr.userPrincipalName))
| project DisabledUPN = tolower(tostring(tr.userPrincipalName))
)
| summarize DisabledTime = max(TimeGenerated) by DisabledUPN;
// Identity context, keyed on UPN
let IdentityContext = IdentityInfo
| where TimeGenerated > ago(IdentityLookback)
| summarize arg_max(TimeGenerated, AccountDisplayName, Department, JobTitle, AssignedRoles, GroupMembership, UserType) by AccountUPN = tolower(AccountUPN);
Attempts
| extend HighRiskRow = RiskLevel in ("high", "medium")
| summarize
StartTime = min(TimeGenerated),
EndTime = max(TimeGenerated),
AttemptCount = count(),
InteractiveCount = countif(IsInteractiveFlag == true),
HighRiskCount = countif(HighRiskRow),
AppCount = dcount(AppDisplayName),
AppSet = make_set(AppDisplayName, 10),
ClientApps = make_set(ClientAppUsed, 10),
RiskLevels = make_set(RiskLevel, 5),
SourceTables = make_set(SourceTable, 2),
Country = max(Country),
City = max(City)
by UserPrincipalName, IPAddress
| join kind=leftouter DisableEvents on $left.UserPrincipalName == $right.DisabledUPN
| join kind=leftouter IdentityContext on $left.UserPrincipalName == $right.AccountUPN
| extend DaysSinceDisabled = datetime_diff('day', StartTime, DisabledTime)
| extend IsConfirmedStale = isnotempty(DisabledTime) and DaysSinceDisabled >= StaleDisableDays
| extend HighRisk = HighRiskCount > 0
| where InteractiveCount > 0 or IsConfirmedStale or HighRisk
| extend Name = tostring(split(UserPrincipalName, "@", 0)[0]), UPNSuffix = tostring(split(UserPrincipalName, "@", 1)[0])
| extend Risk_1 = iff(InteractiveCount > 0, "InteractiveAttempt", "")
| extend Risk_2 = iff(IsConfirmedStale, "StaleDisabledAccount", "")
| extend Risk_3 = iff(HighRisk, "HighSignInRisk", "")
| extend Risk_4 = iff(AppCount >= AppThreshold, "MultiAppEnumeration", "")
| extend Risk_5 = iff(tostring(AssignedRoles) has_any (SensitiveRoles), "PrivilegedFormerAccount", "")
| extend Risk_6 = iff(AttemptCount >= VolumeThreshold, "HighVolume", "")
| extend Risk_7 = iff(UserType =~ "Guest", "GuestAccount", "")
| extend RiskIndicators = trim(@"\s\|\s*$", strcat(
iff(isnotempty(Risk_1), strcat(Risk_1, " | "), ""),
iff(isnotempty(Risk_2), strcat(Risk_2, " | "), ""),
iff(isnotempty(Risk_3), strcat(Risk_3, " | "), ""),
iff(isnotempty(Risk_4), strcat(Risk_4, " | "), ""),
iff(isnotempty(Risk_5), strcat(Risk_5, " | "), ""),
iff(isnotempty(Risk_6), strcat(Risk_6, " | "), ""),
iff(isnotempty(Risk_7), strcat(Risk_7, " | "), "")
))
| project
StartTime, EndTime, RiskIndicators,
UserPrincipalName, Name, UPNSuffix, IPAddress, Country, City,
AttemptCount, InteractiveCount, AppCount, AppSet, ClientApps, RiskLevels, SourceTables,
DisabledTime, DaysSinceDisabled, IsConfirmedStale,
AccountDisplayName, Department, JobTitle, AssignedRoles, GroupMembership, UserType
| sort by StartTime desc
You can also download this as an analytic rule and import it directly to Sentinel.
Follow my repo - GitHub
What You Should Do Next
- Set DisableLookback to match your AuditLogs retention. The stale-disable signal can only resolve disables it can still see; default it to wherever your audit data actually reaches.
- Run the query manually over the last day before deploying. Most of what fires should be interactive attempts and stale-credential use. If non-interactive recent disables are still slipping through, your StaleDisableDays is too low for your token lifetimes.
- Decide your interactive threshold. In a large tenant a single former user trying to log in once is common and low-value. If that volume is noisy, require two or more interactive attempts and let the stale and risk paths carry the rest.
- Build the re-enable companion on the audit log. This rule catches attempts against an account that is still off. An account that gets switched back on and then used is the more dangerous event and lives in AuditLogs.
Class dismissed