Query Details
id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
name: "SigninLogs — Slow & Low Password Spray (Multi-Day Evasion)"
version: 1.0.0
kind: Scheduled
description: |
Detects password spray campaigns deliberately spread over multiple days to evade per-hour detection thresholds. Identifies a single IP targeting 3+ distinct accounts per day across 3+ separate days. Classic TTP from adversary groups using purchased credential lists at low throttle rates. MITRE ATT&CK: T1110.003 (Password Spraying).
severity: High
requiredDataConnectors:
- connectorId: AzureActiveDirectory
dataTypes:
- SigninLogs
queryFrequency: P1D
queryPeriod: P7D
triggerOperator: gt
triggerThreshold: 0
tactics:
- CredentialAccess
relevantTechniques:
- T1110
query: |
// ---- Network Allowlist (exclude trusted IPs / CIDR / ranges) --------------
let _allow = materialize(union isfuzzy=true (print R="" | take 0), (_GetWatchlist('NetworkAllowlist') | project R = tostring(IPOrRange)) | where isnotempty(R));
let _allowCIDR = toscalar(_allow | where not(R matches regex @'^\d+\.\d+\.\d+\.\d+-\d+\.\d+\.\d+\.\d+$') | extend R = iff(R has '/', R, strcat(R, '/32')) | summarize make_list(R));
let _allowRange = toscalar(_allow | where R matches regex @'^\d+\.\d+\.\d+\.\d+-\d+\.\d+\.\d+\.\d+$' | summarize make_list(R));
let _ExcludeAllowlistedIPs = (T:(IPAddress:string)) {
T
| extend IPAddress = tostring(IPAddress)
| where array_length(_allowCIDR) == 0 or isnull(ipv4_is_in_any_range(IPAddress, _allowCIDR)) or not(ipv4_is_in_any_range(IPAddress, _allowCIDR))
| mv-apply _r = _allowRange to typeof(string) on (
extend _lo = tostring(split(_r,'-')[0]), _hi = tostring(split(_r,'-')[1])
| extend _inRange = ipv4_compare(IPAddress, _lo) >= 0 and ipv4_compare(IPAddress, _hi) <= 0
| summarize _anyInRange = max(toint(_inRange)))
| where isnull(_anyInRange) or _anyInRange == 0
| project-away _anyInRange
};
// ---------------------------------------------------------------------------
let lookback = 7d;
let minAccountsPerDay = 3;
let minActiveDays = 3;
SigninLogs
| invoke _ExcludeAllowlistedIPs()
| where TimeGenerated > ago(lookback)
| where ResultType != "0"
| where isnotempty(IPAddress)
| extend Day = bin(TimeGenerated, 1d)
| summarize
AccountsOnDay = dcount(UserPrincipalName),
FailuresOnDay = count()
by IPAddress, Day
| where AccountsOnDay >= minAccountsPerDay
| summarize
DaysActive = dcount(Day),
TotalAccounts = sum(AccountsOnDay),
TotalFailures = sum(FailuresOnDay),
ActiveDays = make_set(Day, 10),
StartDay = min(Day),
EndDay = max(Day)
by IPAddress
| where DaysActive >= minActiveDays
| project
TimeGenerated = EndDay,
IPAddress,
DaysActive,
TotalAccounts,
TotalFailures,
StartDay,
EndDay,
ActiveDays
entityMappings:
- entityType: IP
fieldMappings:
- identifier: Address
columnName: IPAddress
customDetails:
DaysActive: DaysActive
TotalAccounts: TotalAccounts
TotalFailures: TotalFailures
alertDetailsOverride:
alertDisplayNameFormat: "Slow & Low Spray from {{IPAddress}} — {{DaysActive}} active days, {{TotalAccounts}} accounts"
alertDescriptionFormat: "IP {{IPAddress}} spread {{TotalAccounts}} targeted accounts across {{DaysActive}} active days — multi-day evasion of per-hour spray detection."
incidentConfiguration:
createIncident: true
groupingConfiguration:
enabled: true
reopenClosedIncident: false
lookbackDuration: P1D
matchingMethod: AllEntities
groupByEntities:
- IP
This query is designed to detect a specific type of cyber attack known as a "password spray" campaign. Here's a simplified breakdown of what it does:
Purpose: The query identifies password spray attacks that are spread over multiple days. These attacks are designed to evade detection systems that typically monitor for rapid, high-volume login attempts within a short time frame.
Detection Criteria:
Exclusions:
Data Source:
Output:
Alerting:
Severity and Techniques:
In essence, this query helps security teams identify and respond to stealthy password spray attacks that are deliberately slow and spread out over time to avoid detection.

David Alonso
Released: April 20, 2026
Tables
Keywords
Operators