Microsoft Sentinel ships with hundreds of built-in analytics rules. Most experienced SOC teams turn them all on, get buried in alerts within 48 hours, start suppressing everything, and end up with a SIEM that's technically running detections while practically doing nothing.

The problem isn't the rules. It's that generic rules written for every environment will always produce noise in your specific environment. Good detection engineering means writing rules that match your environment's specific baseline — what's normal for your users, your infrastructure, your tooling — and alerting on deviations from that.

Here's how to actually do that in Microsoft Sentinel using KQL.

What this covers

KQL patterns for building detection rules, tuning false positives, dynamic thresholds, entity-based enrichment, and the structural difference between a query that finds something and a rule that fires correctly in production.

The Anatomy of a Production Detection Rule

Most analysts write detection queries the same way they write investigation queries — pull the event, add filters, see what comes back. That works for investigation. It creates noise in production.

A production rule has five components:

  1. Population query — what events are you looking at
  2. Baseline suppression — what known-good patterns are excluded
  3. Anomaly threshold — what level of the behavior triggers an alert
  4. Entity mapping — what entities (user, host, IP) Sentinel should track for correlation
  5. Alert enrichment — what additional context the alert surfaces for the analyst

Most rules that get written have items 1 and 3. The ones that perform well in production have all five.

Pattern 1: Static Allowlist Suppression

The most common false positive source is a known process or account doing something that looks suspicious but is legitimate in your environment. Fix it with an explicit allowlist, not by weakening the detection logic.

Sentinel KQL — Process creation with allowlist suppression
// Detect scripting engines spawned from Office apps
// with explicit suppression for known-good patterns
let AllowedParents = dynamic([
    "teams.exe", "onedrive.exe", "msiexec.exe"
]);
let SuspiciousChildren = dynamic([
    "powershell.exe", "cmd.exe", "wscript.exe",
    "cscript.exe", "mshta.exe", "rundll32.exe"
]);
DeviceProcessEvents
| where TimeGenerated > ago(1d)
| where InitiatingProcessFileName has_any(
    "winword.exe", "excel.exe", "outlook.exe",
    "powerpnt.exe", "onenote.exe"
  )
| where FileName in~ (SuspiciousChildren)
| where not(InitiatingProcessFileName in~ (AllowedParents))
| project TimeGenerated, DeviceName, AccountName,
    InitiatingProcessFileName, FileName,
    ProcessCommandLine, InitiatingProcessCommandLine
| order by TimeGenerated desc
Allowlist management rule

Document every entry you add to an allowlist with a ticket reference and a date. Allowlists without documentation become security holes — nobody knows why the entry is there six months later, and nobody wants to remove it in case something breaks.

Pattern 2: Dynamic Threshold Baselining

Static thresholds fail in environments with variable activity. "Alert if more than 50 failed logins" fires every Monday morning during a forced password reset cycle and misses a slow-and-low brute force at 12 attempts per hour. Dynamic baselining fixes this.

Sentinel KQL — Failed login dynamic threshold
// Build a 14-day baseline per account, alert on deviations
let BaselinePeriod = 14d;
let DetectionWindow = 1h;
let DeviationMultiplier = 3.0;
let Baseline = SecurityEvent
    | where TimeGenerated between (ago(BaselinePeriod) .. ago(DetectionWindow))
    | where EventID == 4625
    | summarize
        AvgFailures = avg(count()),
        StdDevFailures = stdev(count())
        by TargetAccount, bin(TimeGenerated, 1h)
    | summarize
        BaselineAvg = avg(AvgFailures),
        BaselineStdDev = avg(StdDevFailures)
        by TargetAccount;
SecurityEvent
| where TimeGenerated > ago(DetectionWindow)
| where EventID == 4625
| summarize CurrentFailures = count() by TargetAccount
| join kind=inner Baseline on TargetAccount
| extend Threshold = BaselineAvg + (DeviationMultiplier * BaselineStdDev)
| where CurrentFailures > Threshold
| extend DeviationFactor = round(CurrentFailures / max_of(BaselineAvg, 1), 1)
| project TargetAccount, CurrentFailures, BaselineAvg,
    Threshold, DeviationFactor
| order by DeviationFactor desc

Pattern 3: Multi-Signal Correlation

Single-event detections have high false positive rates. Multi-signal detections — where an alert requires two or more behaviors to be present in the same time window for the same entity — produce far fewer alerts with far higher signal quality.

Sentinel KQL — Impossible travel + new device correlation
// Alert when: login from two countries within 2 hours
// AND first-time device seen for that account
let TravelWindow = 2h;
let RecentLogins = SigninLogs
    | where TimeGenerated > ago(1d)
    | where ResultType == "0"
    | project TimeGenerated, UserPrincipalName,
        Location, IPAddress, DeviceDetail;
let ImpossibleTravel = RecentLogins
    | summarize
        Locations = make_set(Location),
        LoginTimes = make_list(TimeGenerated)
        by UserPrincipalName, bin(TimeGenerated, TravelWindow)
    | where array_length(Locations) > 1;
let NewDevices = RecentLogins
    | summarize FirstSeen = min(TimeGenerated) by
        UserPrincipalName, tostring(DeviceDetail)
    | where FirstSeen > ago(24h);
ImpossibleTravel
| join kind=inner NewDevices on UserPrincipalName
| project UserPrincipalName, Locations, FirstSeen
| extend RiskScore = 85
| order by RiskScore desc

Common KQL Detection Anti-Patterns

Anti-Pattern Problem Fix
Bad `where field != ""` Doesn't handle null — null != "" evaluates to null, not true Use `isnotempty(field)` instead
Bad `where field == "value"` on mixed-case data Case-sensitive match misses variations Use `=~` for case-insensitive comparison
Bad `contains` on high-cardinality fields Extremely slow on large tables, triggers full scan Use `has` for word-boundary matching, or `startswith`/`endswith`
Bad Join without `kind=` specified Defaults to innerunique — deduplicates left side unexpectedly Always specify join kind explicitly
Bad No time filter before join Joins on unbounded tables destroy query performance Filter time range before every join leg

Entity Mapping for Incident Correlation

Sentinel's entity mapping feature links alerts to tracked entities — accounts, hosts, IPs — so that related alerts from different rules automatically surface on the same incident. This is what enables the "multiple alerts on same account" correlation that would otherwise require manual investigation.

In the analytics rule configuration, map your KQL output columns to entity types:

Entity mapping payoff

A single incident that auto-correlates a failed login alert, a process creation alert, and a data exfil alert on the same account — without any manual pivot — is the difference between a 2-hour investigation and a 20-minute one. Set up entity mapping on every rule you write.

Rule Tuning Workflow

Every new rule goes through the same tuning cycle before it earns a place in production:

  1. Run in hunting mode first — execute the query manually over 30 days, review every result, categorize as true positive / false positive / benign true positive
  2. Build suppressions for benign true positives — known-good behavior that matches the rule gets added to an allowlist with a ticket reference
  3. Enable in alert-but-don't-close mode — let it run for a week, review every alert, adjust thresholds based on real volume
  4. Set severity and playbook assignment — only after step 3 confirms signal quality
  5. Document the rule — required logs, expected false positives, tuning history, investigation steps for the analyst who gets the alert at 2am
Weekly Intelligence Pack — $14.99/mo
A new production KQL rule every Tuesday

Every issue includes a fully built detection rule — entity mapping, suppression logic, investigation steps, MITRE mapping, and ticket wording. For CrowdStrike, Sentinel, or Splunk, rotating weekly. Built from 10 years of real SOC operations.

Join — $14.99/mo See a Sample Issue
Founding member pricing locked for life · 30-day money back