Skip to content
Poshan Bhandari
← All Articles
Threat Hunting

A Practical Approach to Threat Hunting with KQL

June 2, 2026·8 min read

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.

kql
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 desc

The 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.

Threat HuntingKQLMicrosoft SentinelDefender for Endpoint