Behavioral analytics (UEBA)
Baseline normal, flag meaningful deviation.
Your bank never wrote a rule that says *block anyone buying a jet-ski in Lima at 3am*. It learned your normal instead: where you shop, how much you spend, what hours you are awake. Then it flagged the charge that did not fit the shape of you. UEBA (User and Entity Behavior Analytics) points that same trick at your identities and your workloads. Rule and signature detections catch the patterns somebody already knew to look for. UEBA catches the new, the quiet and the insider, by learning what normal looks like and measuring how far each fresh observation sits from it.
The four words the whole idea rests on
Four words carry this entire discipline, so pin them down before you touch a query. An entity is anything with repeat behavior worth profiling: a person, a service account, a host, a container workload, a source IP address. A feature is one measurable dimension of that behavior, like login hour, source ASN (autonomous system number, the ID of the network block an address belongs to), bytes sent out per hour, how many distinct resources got touched, or how many API calls (programmatic requests to a service, with no human clicking anything) it makes per minute. A baseline is the statistical profile of one feature for one entity over a training window, say 21 days. An anomaly score is how far a fresh observation sits from that baseline, expressed in standard units. That is the whole machine: build baselines per entity per feature, then score every new event against them.
The payoff is that none of this needs advance knowledge of the attack. A rule has to name the bad thing before it happens (*a secret read by a principal that is not on the allowlist*). A baseline only needs the bad behavior to look different from the settled norm, which compromise, stolen credentials and insider abuse nearly always do. Take a service account that has read reports-db-creds from one ASN every working day for a month. Then it reads root-signing-key from a hosting-provider ASN at 03:14 UTC. That scores high, and nobody ever had to write a rule for that exact case.
The math, and where plain statistics lets you down
The obvious scorer is the z-score: (observed − mean) / stdev. It has one flaw, and in security that flaw is fatal. The outlier spikes you are hunting are the same numbers that drag the mean and the standard deviation upward, so a single spike sitting in your training data raises the bar and hides the next one. Production UEBA uses the modified z-score instead. That version is built on the *median* and on MAD (median absolute deviation, the median of |x − median|). Both the median and MAD shrug off outliers, so one strange hour in history does not poison the baseline. The 0.6745 constant rescales the answer so it reads on the same scale as an ordinary z-score, because for normally distributed data MAD ≈ 0.6745 σ. The conventional flag threshold of 3.5 comes from Iglewicz and Hoaglin's outlier-labeling work.
Two more pieces finish the engine. EWMA (exponentially weighted moving average, a rolling average that counts recent hours more heavily than old ones) lets a baseline follow *legitimate* drift, so a team that genuinely doubled its traffic last month does not alert forever. Handling seasonality stops you paging on a 3am batch job that has run at 3am since the day it shipped, while keeping a 3am human login every bit as suspicious as it should be.
# Robust anomaly score for one entity/feature (the core UEBA computation)import numpy as np# 21 days of hourly "bytes egressed" for service account svc-reporting (504 points)history = np.load("svc-reporting.bytes_out.npy")observed = 8.4e9 # this hour# EWMA baseline: recent hours weighted more (alpha=0.1 ~= 10h memory)alpha, ewma = 0.1, history[0]for x in history:ewma = alpha * x + (1 - alpha) * ewma# Modified z-score: median + MAD resist the past spikes that break plain stdevmed = np.median(history)mad = np.median(np.abs(history - med)) or 1e-9 # guard divide-by-zerorobust_z = 0.6745 * (observed - med) / madprint(f"ewma_baseline={ewma:.2e} median={med:.2e} observed={observed:.2e}")print(f"robust_z={robust_z:.1f} {'ANOMALY' if abs(robust_z) > 3.5 else 'normal'}")# ---- output ----# ewma_baseline=1.31e+08 median=1.20e+08 observed=8.40e+09# robust_z=182.9 ANOMALY
Letting the platform do the math
You rarely hand-roll any of this in production, because your SIEM (security information and event management platform, the system that swallows your logs and runs detections over them) already ships it. In Microsoft Sentinel and Azure Data Explorer you write KQL (Kusto Query Language). make-series chops events into a per-entity time series, and series_decompose_anomalies() splits that series into its seasonal part, its trend and the leftover residual, then hands back an anomaly flag (+1/-1/0), a score and the fitted baseline in one call.
The signature is series_decompose_anomalies(Series, Threshold, Seasonality, Trend, ...), and every argument in the query below is doing real work. 3.0 is the anomaly threshold, a *k* multiplier on a Tukey's-fence test over the residuals: the default 1.5 flags mild wobbles, 3.0 keeps only the loud ones. The -1 tells it to work out seasonality on its own, picking up daily and weekly cycles. 'linefit' extracts the trend with linear regression, where the default 'avg' assumes there is no trend at all. Read the result rows carefully. The baseline for svc-reporting is 2.1 logins an hour, so 47 of them at 03:00 scores 6.2.
// Per-user hourly sign-in volume: auto-baseline + anomaly flag in one querylet lookback = 21d;SigninLogs| where TimeGenerated > ago(lookback)| where ResultType == "0" // successful sign-ins only| make-series events = count() default=0on TimeGenerated step 1h by UserPrincipalName| extend (flag, score, baseline) =series_decompose_anomalies(events, 3.0, -1, 'linefit')| mv-expand TimeGenerated to typeof(datetime), events to typeof(long),flag to typeof(int), score to typeof(real), baseline to typeof(real)| where flag == 1 and TimeGenerated > ago(1d) // positive spikes in last day| project UserPrincipalName, TimeGenerated, events,score = round(score,1), baseline = round(baseline,1)| sort by score desc// UserPrincipalName TimeGenerated events score baseline// [email protected] 2026-07-13 03:00:00 47 6.2 2.1// [email protected] 2026-07-13 09:00:00 31 3.4 12.0 <- borderline
Impossible travel, and judging people against their peers
Not every behavioral signal is a volume spike. Impossible travel is a baseline made of physics rather than statistics: two authenticated sessions for one identity, from places too far apart to travel between in the time that actually passed. In Splunk's SPL (Search Processing Language), streamstats walks each user's events in time order and carries the previous country and timestamp forward, so you can work out the gap between one login and the next. A hop between two countries inside a fraction of an hour is not a rare baseline at all. Physics says it cannot happen.
Peer grouping covers the other side of the problem. Score an entity against its cohort, meaning people in the same role or on the same team, so a new hire inherits a sensible baseline before they have built up any history of their own. It also stops one noisy account excusing itself on the grounds that being noisy is *its* normal.
index=auth action=success earliest=-24h| sort 0 user, _time| streamstats current=f last(src_country) as prev_countrylast(_time) as prev_time by user| eval gap_hours = round((_time - prev_time)/3600, 2)| where isnotnull(prev_country) AND src_country != prev_country AND gap_hours < 2| table _time, user, prev_country, src_country, gap_hours* _time user prev_country src_country gap_hours* 2026-07-13 03:14 svc-reporting US RU 0.20* -- 0.2h between a US and RU session for the same identity: physically impossible
Prove the model works before you trust it
A behavioral model you have never exercised is a hope, not a detection. Cause the deviation on purpose, then check that the baseline actually moves. This is where Atomic Red Team earns its keep. Invoke-AtomicTest runs small, self-contained attacker behaviors mapped to ATT&CK (MITRE's public catalogue of the techniques real attackers use). Technique T1098.001 (*Account Manipulation: Additional Cloud Credentials*) ships an atomic that creates an AWS access key on a test principal, which is exactly the odd credential event your CloudTrail analytics (the AWS log of every API call made in an account) ought to score.
For the sign-in query above there is no atomic to run, so seed the deviation yourself: script an off-hours logon with a dedicated test account. Either way the drill is the same. Wait for the logs to land, re-run the analytic, and check that the seeded entity comes back with baseline = 0, meaning first-ever activity in that slot, and a positive flag. If it never shows up, your window, your step size or your threshold is wrong. Fix that now, in daylight, rather than at 2am in the middle of a real incident.
# List the atomics for Account Manipulation: Additional Cloud CredentialsPS> Invoke-AtomicTest T1098.001 -ShowDetailsBriefPathToAtomicsFolder = C:\AtomicRedTeam\atomicsT1098.001-1 Azure AD Application Hijacking - Service PrincipalT1098.001-2 Azure AD Application Hijacking - App RegistrationT1098.001-3 AWS - Create Access Key and Secret Key# Run test 3 against a dedicated test principal (uses your configured AWS creds)PS> Invoke-AtomicTest T1098.001 -TestNumbers 3PathToAtomicsFolder = C:\AtomicRedTeam\atomicsExecuting test: T1098.001-3 AWS - Create Access Key and Secret KeyDone executing test: T1098.001-3 AWS - Create Access Key and Secret Key# The CreateAccessKey event lands in CloudTrail for the test principal. For the# Sentinel sign-in analytic, seed with a scripted 03:00 logon instead; after# ingestion the seeded entity should surface in the anomaly query:# [email protected] 2026-07-13 03:22:00 1 4.8 0.0# baseline=0.0 -> first-ever 03:00 logon for this principal = high score. Model works.
What it is good at, and where it falls over
Every strength here casts its own shadow. UEBA is genuinely good at the unknown and at the insider, but an anomaly is not an intent. A product launch, a datacentre migration or a brand new project is a real deviation with a boring explanation, so an untuned analytic becomes a noise machine that teaches responders to ignore it. It has a cold-start problem: no clean history means no baseline you can trust, which is the whole reason peer grouping matters for new entities. It suffers drift, because normal really does change. It can be walked past by patient evasion, where an attacker ramps activity up so gradually that the baseline moves along with them and every individual step stays under the threshold. And it costs money. Per-entity time series across millions of identities is real storage and real compute, so pick the features that actually separate benign from malicious instead of baselining everything you happen to collect.
A grown-up detection program refuses the either/or. Precise rule and TTP (tactics, techniques and procedures) detections cover the techniques you already know about, and they buy you high precision with low recall: few false alarms, plenty of misses. Behavioral analytics cover deviation and give you the mirror image, high recall with low precision. Correlation is the step that turns weak signals into confident ones. A lone anomaly is a hunting lead, and a lead is not an incident. An anomaly plus a suspicious API call plus a burst of outbound data is something you can call an incident. That risk-aggregation step is what makes it safe to page a human on a behavioral signal rather than only hunting with it. A high-confidence one, say a service account pushing 70 times its baseline of data out at 3am from an ASN nobody has ever seen before, is precisely the thing that opens an investigation. The next lesson, Cloud DFIR process (digital forensics and incident response), starts right at that handover: how to scope, preserve and collect evidence once a behavioral anomaly has graduated into a confirmed incident.
Try this
Pull one entity's feature summary out of your UEBA job or your SIEM's anomaly table: login hour, how many different networks it connected from, admin API call count. Then explain the high score out loud using those features and nothing else. No gut feeling allowed.
// Illustrative KQL — replace with your UEBA tableBehaviorAnalytics| where TimeGenerated > ago(7d)| where UserName == "j.alvarez"| project TimeGenerated, ActivityInsights, InvestigationPriority, DevicesInsights| order by InvestigationPriority desc| take 5# InvestigationPriority 25+# ActivityInsights: {"unusual_hour":true,"new_country":true,"admin_ops":3}# Write the human sentence: "First admin API burst from a new country at 03:10 local."
Takeaway
UEBA baselines normal and scores deviation. It sits alongside your signature and rule detections; it does not replace them. Segment your entities properly, put the contributing features on the face of every alert, and tune it for precision the way you would tune any other detection.
Next step: baseline one privileged cohort for two weeks with paging switched off, then turn paging on only for scores whose top features you can explain to an analyst in a single sentence.