Agent Foskett Academy • KQL Academy • Module 14 • Lesson 165 • Detection Engineering & Proactive Threat Hunting

Lesson 165 — Building a Baseline Before Setting the Threshold

Lesson 164 taught us how to reduce false positives without destroying detection coverage. But there is another easy way to create a noisy detection: choose a threshold because the number simply feels suspicious.

Ten PowerShell executions in an hour might be extraordinary for one user and completely normal for another. A device making 200 outbound connections might be unusual on a workstation but routine on an application server. Before deciding when activity becomes suspicious, we need evidence showing what normal activity actually looks like.

A threshold should be the result of the investigation, not the starting assumption. First measure normal. Then decide what deserves attention.
Agent Foskett KQL Academy behavioural baseline and detection threshold
Your detection problem

You know the behaviour you want to detect, but you do not yet know what number should trigger an alert. Build the baseline before choosing the threshold.

✓ Measure historical activity
✓ Compare users and devices
✓ Calculate normal ranges
✓ Test current activity against the baseline

Detection briefing

BEHAVIOUR WORTH DETECTING ↓ DO NOT PICK A NUMBER YET ↓ COLLECT HISTORICAL ACTIVITY ↓ MEASURE NORMAL VOLUME ↓ COMPARE USERS + DEVICES ↓ UNDERSTAND VARIATION ↓ BUILD EXPECTED RANGES ↓ TEST CANDIDATE THRESHOLDS ↓ ALERT ON MEANINGFUL DEVIATION

Detection objective

Use KQL to establish a historical baseline for behaviour, measure how activity varies across users and devices, identify expected ranges and use those observations to choose a threshold that can be explained and defended.

Detection engineer's rule

“Unusual” is contextual. A fixed number has no security meaning until you understand what the same entity normally does and how much legitimate variation exists in the environment.

Stage 1 — start with history, not the threshold

Suppose we want to detect unusually frequent PowerShell execution. Instead of immediately deciding that 20 executions per hour is suspicious, first measure hourly activity over a useful historical period.

01-measure-historical-powershell-volume.kql
12345678910
DeviceProcessEvents
| where Timestamp > ago(30d)
| where FileName in~ ("powershell.exe", "pwsh.exe")
| summarize Executions=count(),
            Devices=dcount(DeviceName),
            Users=dcount(AccountName)
          by bin(Timestamp, 1h)
| order by Timestamp asc

Why 30 days?

The right baseline period depends on the environment. Thirty days can expose daily and weekly patterns, but highly seasonal workloads may require longer. The goal is enough history to represent legitimate operating behaviour.

Watch for poisoned history

A baseline is not automatically trustworthy because it is historical. If the baseline period contains an unresolved compromise or abnormal rollout, that activity can become part of your definition of “normal”.

Stage 2 — build baselines per user

An environment-wide average can hide important differences. Administrative users may execute PowerShell regularly while most business users rarely do. Measure the behaviour at the entity level.

02-build-user-baselines.kql
1234567891011121314
let HourlyActivity =
    DeviceProcessEvents
    | where Timestamp between (ago(30d) .. ago(1d))
    | where FileName in~ ("powershell.exe", "pwsh.exe")
    | summarize HourlyExecutions=count()
              by AccountName, bin(Timestamp, 1h);
HourlyActivity
| summarize Average=avg(HourlyExecutions),
            Median=percentile(HourlyExecutions, 50),
            P95=percentile(HourlyExecutions, 95),
            Maximum=max(HourlyExecutions),
            ActiveHours=count()
          by AccountName
| order by P95 desc

Average is not enough

Averages can be distorted by occasional spikes. Median and percentile values help describe the distribution and show what activity looks like during most observed periods.

P95 is evidence, not magic

The 95th percentile can be a useful reference point, but it is not automatically the correct alert threshold. Detection engineering still requires context, validation and testing.

Stage 3 — compare device baselines too

The same account can behave differently depending on the endpoint. Servers, jump hosts, developer workstations and standard user devices may have very different legitimate execution patterns.

03-build-device-baselines.kql
12345678910111213
let HourlyDeviceActivity =
    DeviceProcessEvents
    | where Timestamp between (ago(30d) .. ago(1d))
    | where FileName in~ ("powershell.exe", "pwsh.exe")
    | summarize HourlyExecutions=count()
              by DeviceName, bin(Timestamp, 1h);
HourlyDeviceActivity
| summarize Average=avg(HourlyExecutions),
            P95=percentile(HourlyExecutions, 95),
            Maximum=max(HourlyExecutions)
          by DeviceName
| order by P95 desc

One threshold can punish the wrong systems

A global threshold low enough to detect abnormal workstation activity may constantly alert on legitimate automation servers. A threshold high enough for those servers may miss suspicious workstation behaviour entirely.

Segment before you suppress

Where behaviour differs materially by device role or user population, consider separate baselines or detection logic rather than broad exclusions.

Stage 4 — compare current behaviour with historical expectation

Now bring recent activity together with the historical baseline. This makes the detection relative to what each account normally does instead of relying on one arbitrary global number.

04-compare-current-activity-with-baseline.kql
12345678910111213141516171819202122232425
let Baseline =
    DeviceProcessEvents
    | where Timestamp between (ago(30d) .. ago(1d))
    | where FileName in~ ("powershell.exe", "pwsh.exe")
    | summarize HourlyExecutions=count()
              by AccountName, bin(Timestamp, 1h)
    | summarize P95=percentile(HourlyExecutions, 95),
                HistoricalMax=max(HourlyExecutions)
              by AccountName;
let Current =
    DeviceProcessEvents
    | where Timestamp > ago(1h)
    | where FileName in~ ("powershell.exe", "pwsh.exe")
    | summarize CurrentExecutions=count(),
                Devices=dcount(DeviceName)
              by AccountName;
Current
| join kind=leftouter Baseline on AccountName
| extend P95=coalesce(P95, 0.0)
| extend AboveBaseline =
    CurrentExecutions > P95
| project AccountName, CurrentExecutions,
          P95, HistoricalMax, Devices,
          AboveBaseline
| order by CurrentExecutions desc

New entities need special handling

If an account has no useful history, absence of a baseline does not mean absence of risk. New users, devices and service accounts may need separate logic until enough history exists.

Baseline deviation is a lead

Exceeding historical P95 does not prove compromise. It tells the analyst that the activity deserves context: parent process, command line, device, time, network behaviour and surrounding events.

Stage 5 — test a candidate threshold before alerting

Before converting the baseline into production detection logic, measure how often the candidate threshold would have fired historically. A threshold that would generate hundreds of daily alerts has already told you something important.

05-test-the-candidate-threshold.kql
1234567891011121314151617181920
let Hourly =
    DeviceProcessEvents
    | where Timestamp > ago(30d)
    | where FileName in~ ("powershell.exe", "pwsh.exe")
    | summarize Executions=count()
              by AccountName, bin(Timestamp, 1h);
let UserBaseline =
    Hourly
    | summarize P95=percentile(Executions, 95)
              by AccountName;
Hourly
| join kind=inner UserBaseline on AccountName
| where Executions > P95
| summarize CandidateAlerts=count(),
            AlertDays=dcount(startofday(Timestamp)),
            PeakExecutions=max(Executions)
          by AccountName
| order by CandidateAlerts desc

Backtesting changes the conversation

Instead of arguing whether a threshold “sounds right”, you can show how many alerts it would have generated, which identities would dominate and whether known suspicious cases remain visible.

Operational cost matters

A technically valid detection that overwhelms analysts is not operationally mature. Threshold testing should consider both security coverage and the investigation workload it creates.

Agent Foskett's baseline-to-threshold workflow

DEFINE THE BEHAVIOUR ↓ COLLECT REPRESENTATIVE HISTORY ↓ CHECK THE HISTORY FOR ABNORMAL PERIODS ↓ MEASURE BY ENTITY User • Device • Role ↓ CALCULATE DISTRIBUTION Average • Median • P95 • Maximum ↓ COMPARE CURRENT ACTIVITY ↓ TEST CANDIDATE THRESHOLD ↓ MEASURE EXPECTED ALERT VOLUME ↓ VALIDATE SUSPICIOUS OUTLIERS ↓ DEPLOY + REVIEW + RECALIBRATE
The baseline explains normal behaviour. The threshold decides when deviation becomes important enough to investigate. They are related, but they are not the same thing.

Weak thresholds versus evidence-based thresholds

ApproachProblemBetter detection-engineering question
Alert when count > 10The number has no environmental context.How often does this entity normally exceed 10?
Use one threshold for every deviceDifferent device roles may have radically different behaviour.Should workstations and automation servers have separate baselines?
Use only the averageSpikes can distort the result and hide the distribution.What do median, P95 and maximum show?
Trust all historical activityCompromise or abnormal projects can contaminate the baseline.Is the baseline period representative and validated?
Deploy without backtestingAlert volume and blind spots remain unknown.How would this threshold have behaved over the last 30 days?

Write the threshold decision like a detection engineer

Example: Thirty days of historical PowerShell telemetry was analysed by account and device. Standard business users showed very low hourly execution volumes, while administrative and automation identities displayed materially higher and more variable activity. Rather than applying a single global threshold, the detection was evaluated against entity-level historical distributions. Candidate thresholds were backtested to measure expected alert volume, and deviations above normal ranges were retained as investigation leads rather than treated as proof of compromise. The resulting threshold can therefore be traced to observed environmental behaviour instead of an arbitrary number.

Lesson 165 key takeaways

  • Do not choose a detection threshold before measuring normal behaviour.
  • Use a historical period that represents legitimate environmental activity.
  • Remember that historical data can contain abnormal or malicious behaviour.
  • Build baselines at the user, device or role level when behaviour differs materially.
  • Average alone does not describe a behavioural distribution.
  • Median, percentiles and maximum values provide useful additional context.
  • A percentile is a reference point, not an automatic security threshold.
  • Handle entities with little or no history separately.
  • Backtest candidate thresholds before production deployment.
  • Measure expected analyst workload as well as detection coverage.
  • Review and recalibrate baselines as the environment changes.

Module 14 — now make the baseline adapt

Lesson 165 replaced arbitrary thresholds with evidence from historical behaviour. But environments change. Static baselines can become stale as users change roles, systems are upgraded and normal workloads shift. Next we look at how to recognise those changes without allowing every new pattern to silently redefine normal.

Next: Lesson 166 — The Baseline Changed — Was It Drift or an Attack?

Continue your KQL investigation training

Module 14 turns investigation knowledge into proactive hunts, reusable analytics and higher-confidence detections.

🔎 KQL Academy — Module 14: Advanced Detection Engineering & Proactive Threat Hunting

Turn investigation knowledge into proactive hunts, reusable analytics and higher-confidence detections.

Build behavioural baselines with KQL before setting detection thresholds

Lesson 165 of the Agent Foskett KQL Academy shows how to use Microsoft Defender XDR telemetry and KQL to measure historical behaviour, compare users and devices, calculate expected ranges and choose defensible detection thresholds.

Replace arbitrary thresholds with evidence

Learn how to use averages, medians, percentiles, historical maximums and backtesting to understand normal activity before deciding when a behavioural deviation should become an investigation lead.