Splunk SPL: Brute-Force Login Detection

By NetMon Hub Editorial ·

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:

  1. Count failures per sourcestats groups every failed-auth event by source IP and target account, producing a failure tally inside the search window.
  2. Spot the follow-up successeventstats adds 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:

SettingRecommended value
ScheduleEvery 15 minutes (cron: */15 * * * *)
Time rangeLast 1 hour (earliest=-1h)
Trigger conditionNumber of results > 0
ThrottlePer src_ip, suppress for 4 hours
ActionsSend 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


Sources