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.

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.
Detection briefing
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.
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 ascWhy 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.
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 descAverage 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.
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 descOne 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.
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 descNew 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.
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 descBacktesting 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
Weak thresholds versus evidence-based thresholds
| Approach | Problem | Better detection-engineering question |
|---|---|---|
| Alert when count > 10 | The number has no environmental context. | How often does this entity normally exceed 10? |
| Use one threshold for every device | Different device roles may have radically different behaviour. | Should workstations and automation servers have separate baselines? |
| Use only the average | Spikes can distort the result and hide the distribution. | What do median, P95 and maximum show? |
| Trust all historical activity | Compromise or abnormal projects can contaminate the baseline. | Is the baseline period representative and validated? |
| Deploy without backtesting | Alert 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.
Continue your KQL investigation training
🔎 KQL Academy — Module 14: Advanced Detection Engineering & Proactive Threat Hunting
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.
