A Practical Approach to Threat Hunting with KQL
Alert-driven detection tells you about the things you already know how to detect. Threat hunting exists for everything else: the behavior that doesn't trip a rule because nobody has written it yet, or because it's deliberately built to blend in with normal activity.
Start from a hypothesis, not a dashboard
The most common mistake in threat hunting is starting from the data: opening a SIEM and looking for 'anything weird.' That's not a hunt, it's browsing. A hunt starts from a specific, falsifiable hypothesis: something like 'if an attacker has valid credentials but no foothold yet, they will attempt interactive sign-ins from unfamiliar client applications outside normal working hours for that identity.' That's a claim you can actually test with a query, and one you can be proven wrong on.
The loop
- Signal: something worth investigating, like a threat intel note, an anomaly in a dashboard, or a gap in existing detection coverage.
- Hypothesis: a specific, testable statement about attacker behavior.
- Query: translate the hypothesis into KQL against the relevant tables.
- Correlation: join across identity, endpoint, and network telemetry to separate signal from noise.
- Investigation: pull the outliers and manually validate them.
- Finding: either a confirmed issue, a detection gap worth closing, or a documented dead end.
A dead end is still a result. Documenting 'we hunted for X and found nothing' is valuable: it tells the next hunter (or the next version of you) that this ground has been covered, and it lets you close a detection gap on confidence instead of a guess.
Example query shape
This is an illustrative example of the kind of query shape used to test the hypothesis above, not output from a specific engagement.
SigninLogs
| where TimeGenerated > ago(14d)
| where ResultType == 0
| extend HourOfDay = datetime_part("Hour", TimeGenerated)
| where HourOfDay !between (7 .. 19)
| summarize SignInCount = count(),
DistinctApps = dcount(AppDisplayName),
Apps = make_set(AppDisplayName)
by UserPrincipalName, IPAddress
| where DistinctApps == 1 and Apps has_any ("Unfamiliar App A", "Unfamiliar App B")
| order by SignInCount descThe query itself is the least interesting part of the hunt. The hypothesis behind it, and the judgment applied when reviewing the output, are what separate a real finding from a list of false positives.
Closing the loop
A hunt that finds something real should produce a detection rule. An incident ticket alone means manually re-running the same hunt every quarter. If a hunt finds nothing, it should still end in documentation of what was tested and why, so the coverage decision is auditable later.