Microsoft Sentinel KQL Mastery for Hunters
Build advanced KQL hunting queries in Microsoft Sentinel joins, summarize, and behavioral baselining explained.
KQL in Sentinel gets compared to SQL a lot, and that comparison undersells it. SQL is built for structured relational data with known schemas. KQL is built for exploring massive, semi-structured log data where you often don’t know the exact shape of what you’re querying until you’ve run a few exploratory passes. Once that distinction clicks, you stop trying to write KQL like SQL and start using the operators that actually make it powerful for hunting.
I spend more time in Sentinel these days than any other platform, mostly because so many of the environments I work with are already deep into the Microsoft ecosystem. The hunting workflow there has its own rhythm once you get past the initial learning curve.
summarize is the single most important operator you’ll use
| If there’s one KQL operator worth truly mastering for hunting, it’s summarize, because it’s what turns raw event streams into the aggregated behavioral views that actual analysis depends on. A basic beaconing hunt might look like: SecurityEvent | where EventID == 3 | summarize count(), make_list(TimeGenerated) by SourceIP, DestinationIP—and from there you pipe that list of timestamps into a custom function calculating interval consistency. |
Say you run a summarize-based query against 30 days of network connection logs from a mid-size Azure tenant. You might start with two million raw rows and end up with a few thousand distinct src/dest pairs after aggregation—still a lot, but now it’s a dataset you can actually apply statistical filtering to, rather than an unmanageable stream of individual connection events.
Joins in KQL behave differently than SQL joins, and that trips people up constantly
KQL’s join operator defaults to inner join, same as SQL, but the syntax and performance characteristics are different enough that copying SQL habits directly gets you into trouble. The kind= leftouter, kind=inner, kind=leftanti syntax needs to be explicit, and leftanti specifically is underused in hunting even though it’s exactly what you need for “show me processes that ran but have no corresponding network connection event” type analysis—useful for catching process injection or hollowing scenarios where the visible process doesn’t match its network behavior.
Performance matters more here than in most SQL contexts too. Joining two large tables without narrowing them down with a where clause first can blow past Sentinel’s query timeout, especially across a 90-day lookback. The pattern that actually works: filter each side of the join down as much as possible before the join happens, not after.
Building behavioral baselines with the anomaly and series functions
KQL has a set of statistical functions—series_decompose_anomalies(), series_fit_line(), and similar—that let you build genuine time-series anomaly detection directly in your hunt queries rather than eyeballing a chart and guessing what looks weird. For something like login volume per user over time, series_decompose_anomalies() will flag statistical outliers automatically, which is a meaningfully different (and more defensible) approach than a hardcoded threshold like “more than 10 logins an hour.”
I’ll admit these functions have a learning curve of their own, and they’re not always the right tool—sometimes a simple percentile-based threshold genuinely is clearer and easier for a junior analyst on your team to maintain later. Use the fancy statistical functions when the underlying behavior actually has meaningful time-series structure (login volume, data transfer size over the day), and don’t reach for them just because they’re available.
Watchlists and external data enrichment inside the query itself
Sentinel lets you reference watchlists directly inside KQL using the _GetWatchlist() function, which means you can enrich a hunt query with your own curated lists—known service account names, approved admin IPs, whatever context your environment needs—without a separate lookup step outside the query. This is genuinely useful for cutting false positives at the query level rather than filtering them out manually after the fact during triage.
Build watchlists for the recurring exclusions you find yourself adding to every query anyway—approved VPN egress IPs, known scanner service accounts, that kind of thing—and reference them consistently. It’s a small habit that saves a surprising amount of repeated work across your hunt library.
Hunting queries versus analytics rules: know which one you’re building
Sentinel draws a clear line between the Hunting blade, meant for ad hoc exploratory queries you run manually, and Analytics Rules, meant for queries that fire automated alerts on a schedule. A lot of teams write a great hunting query, prove it catches something real, and then never graduate it into an analytics rule—leaving a detection gap where the same intrusion pattern could sit unnoticed until someone happens to rerun that exact hunt again.
The workflow that actually works: hunt first, validate the query’s precision against a few weeks of historical data to understand its false-positive rate, then promote it to a scheduled analytics rule once you trust it enough to alert on automatically. Treat the hunting blade as your R&D environment, not your permanent home for queries that have already proven themselves.