Splunk SPL: Brute-Force Login Detection
SPL For Splunk: Log Management & SIEM Overview · Validated on Splunk Enterprise 10.4 (as of July 2026)
What this query does
The search below works in two passes across your authentication event data:
- Count failures per source —
statsgroups every failed-auth event by source IP and target account, producing a failure tally inside the search window. - Spot the follow-up success —
eventstatsadds a per-source success flag back onto every row, so you can filter to sources that crossed the failure threshold and eventually authenticated successfully.
That combination is the classic brute-force pattern: repeated failures, then a win. A spike of failures with no success is almost certainly noise or a locked account; a spike with a success warrants immediate investigation.
The SPL
index=security sourcetype=linux_secure OR sourcetype=WinEventLog:Security
(action="failure" OR action="success")
earliest=-1h latest=now
| eval src_ip = coalesce(src_ip, src)
| eval account = coalesce(user, dest_user, "unknown")
| stats
count(eval(action="failure")) AS fail_count
count(eval(action="success")) AS success_count
values(account) AS accounts_targeted
min(_time) AS first_seen
max(_time) AS last_seen
BY src_ip
| eventstats max(success_count) AS any_success BY src_ip
| where fail_count >= 10 AND any_success >= 1
| eval duration_mins = round((last_seen - first_seen) / 60, 1)
| eval severity = case(
fail_count >= 100, "critical",
fail_count >= 50, "high",
fail_count >= 10, "medium"
)
| table src_ip, fail_count, success_count, accounts_targeted,
first_seen, last_seen, duration_mins, severity
| sort - fail_count
Pipe-by-pipe walkthrough
index / sourcetype filter
index=security sourcetype=linux_secure OR sourcetype=WinEventLog:Security
(action="failure" OR action="success")
earliest=-1h latest=now
Scope the search to authentication sources only. Adjust index and
sourcetype to match your environment — linux_secure is the default
Splunk add-on field for /var/log/secure; WinEventLog:Security covers
Windows event IDs 4625 (failure) and 4624 (success). The action field is
normalised by the Splunk Common Information Model (CIM)
Authentication data model.
If your add-on does not normalise to CIM, substitute EventCode or
result as appropriate and adjust the eval lines below.
earliest=-1h keeps the search tight; for a scheduled alert running every
15 minutes, use earliest=-15m.
eval src_ip / eval account
| eval src_ip = coalesce(src_ip, src)
| eval account = coalesce(user, dest_user, "unknown")
coalesce picks the first non-null value across field-name variants. Raw
Windows events may populate src rather than src_ip; syslog-based
sources often do the opposite. This normalisation step prevents the same
attacker appearing under two different field names, which would split their
failure counts and let them slip under the threshold.
stats — aggregate per source
| stats
count(eval(action="failure")) AS fail_count
count(eval(action="success")) AS success_count
values(account) AS accounts_targeted
min(_time) AS first_seen
max(_time) AS last_seen
BY src_ip
stats collapses all events in the window to one row per src_ip.
count(eval(...)) is a conditional count — it increments only when the
eval expression is true, so you get separate tallies for failures and
successes without an intermediate where or transaction.
values(account) keeps a multi-value list of every username the source
attempted, useful for distinguishing a targeted single-account attack from
credential-stuffing across many accounts.
eventstats — propagate success flag
| eventstats max(success_count) AS any_success BY src_ip
stats already collapsed to one row per IP, so eventstats here is
effectively a no-op on the row count — it re-annotates each row with the
per-IP maximum of success_count. The reason to use eventstats rather
than a second stats is that it preserves every column from the previous
step; a second stats would require you to re-list every field.
If you extend this query to run on raw events (before the first stats),
eventstats becomes essential: it adds the aggregate back onto each
individual event row, letting the subsequent where filter work correctly
without collapsing the results prematurely.
where — apply alert threshold
| where fail_count >= 10 AND any_success >= 1
This is your alert threshold. fail_count >= 10 within a one-hour
window is deliberately conservative for a lab or staging environment. In
production, tune it against your baseline — a developer hitting the wrong
password three times before lunch will not trigger this, but a scanner
running HTTP POST loops will.
The any_success >= 1 condition is what separates this from a noisy
“too many failures” alert. Without it, you catch scanners; with it, you
catch compromises.
Note: Lowering the threshold to 5 will surface more true positives in aggressive environments but can generate significant noise against shared NAT egress addresses (office proxies, cloud NAT gateways). Consider adding a
NOT src_ip IN (...)exclusion for known-safe aggregation points.
eval duration_mins / eval severity
| eval duration_mins = round((last_seen - first_seen) / 60, 1)
| eval severity = case(
fail_count >= 100, "critical",
fail_count >= 50, "high",
fail_count >= 10, "medium"
)
duration_mins tells you how long the attack ran — a 600-failure event
spanning 30 seconds looks very different from one spanning 55 minutes
(slow, rate-limited scanner vs a fast credential spray). Both are bad;
the timing changes your response priority.
The case() severity banding maps directly to Splunk Enterprise Security
urgency levels if you feed this into a notable event. Adjust the thresholds
to match your environment’s authentication volume.
Wiring it to a Splunk alert
Save the search as a Scheduled Alert in Splunk Web:
| Setting | Recommended value |
|---|---|
| Schedule | Every 15 minutes (cron: */15 * * * *) |
| Time range | Last 1 hour (earliest=-1h) |
| Trigger condition | Number of results > 0 |
| Throttle | Per src_ip, suppress for 4 hours |
| Actions | Send email / create notable event (ES) / webhook to SIEM |
The 4-hour throttle on src_ip prevents alert fatigue if the attacker keeps
probing after the initial detection. Adjust to suit your SOC’s ticketing SLA.
Splunk docs reference: Creating alerts
Adapting for your environment
Forwarded Windows events (no CIM add-on):
Replace action="failure" with EventCode=4625 and action="success" with
EventCode=4624. Replace src_ip with IpAddress and account with
TargetUserName.
SSH only (linux_secure / syslog):
Add "Failed password" to the initial filter and use
rex field=_raw "from (?<src_ip>\d+\.\d+\.\d+\.\d+)" if src_ip is not
extracted by your TA.
High-volume environments:
Add | eval src_subnet = substr(src_ip,1,len(src_ip)-len(split(src_ip,".")[-1])-1)
before stats to group by /24 subnet when attackers rotate across a range.
This is a trade-off: you catch distributed attacks but risk false positives
against large shared subnets.
Trade-offs and known limits
- Point-in-time window: the search covers a fixed lookback (
-1h). An attacker who paces attempts across two overlapping windows at low volume can stay under the threshold. Splunk’stransactioncommand handles session continuity but at significant performance cost on large indexes — thestatsapproach here scales far better. - No geolocation or ASN context: adding
iplocation src_ipafter thewhereclause enriches results with country/city data and is cheap at this row count (post-aggregation). - Shared NAT / VPN egress: a single corporate NAT IP can generate
legitimate failure volumes that mimic brute force. Maintain an exclusion
list in a lookup table and join it before the
whereclause. - Action field normalisation: this query depends on CIM-normalised
actionvalues. If your add-on is inconsistent, results will silently under-count. Verify with| top actionbefore deploying.