How to Apply Anomaly Detection to Authentication Logs

Published August 12, 2026

By Curtis Lambert

anomaly detectionauthentication logsthreat huntingpythonSOC

Point one anomaly detection model at a domain’s authentication events and it will spend its first week flagging your executives and your backup service account. Neither is compromised. Both look strange next to the average user, and the average user is a fiction: nobody authenticates like the mean of 12,000 accounts.

The unit of normal in auth data is the account. Almost every practical decision follows from that.

Pull the right fields first

You need 4624 (successful logon) and 4625 (failed logon) from endpoint and domain controller Security logs, plus 4768 and 4769 if you care about Kerberos. Minimum usable schema: timestamp, target account, host, logon type, source address, authentication package, event ID.

Logon type is the field that carries the meaning. Microsoft’s event 4624 documentation lists the values from 0 (System) through 13 (CachedUnlock). Three matter disproportionately: 3 (Network), 10 (RemoteInteractive, which is RDP), and 9 (NewCredentials, what runas /netonly produces). Drop that column and an RDP session and a scheduled task become the same row.

Why the global model floods your queue

Authentication counts per account span orders of magnitude. Fit anything across all of them and the model learns a mixture distribution whose tails are permanently occupied by service accounts on one end and low-activity humans on the other. The queue that comes out is a list of your most unusual accounts, and it is the same list tomorrow, because nothing about those accounts changed.

The mechanics of scoring outliers are covered in how anomaly detection works in security ops. This post picks up where that approach starts flooding the queue.

Two changes fix most of it: baseline each account against itself, and fall back to a peer group when an account has too little history to have a baseline.

Baseline per account with median and MAD

Mean and standard deviation are the wrong statistics here. Both are pulled by the events you are hunting, so one burst of activity raises the threshold and hides the next one. Median and median absolute deviation barely move.

import numpy as np
import pandas as pd

auth['ts'] = pd.to_datetime(auth['ts'])
auth['hour'] = auth['ts'].dt.floor('h')

hourly = (auth[auth['event_id'] == 4624]
          .groupby(['target_user', 'hour'])
          .size()
          .rename('logons')
          .reset_index())

prof = hourly.groupby('target_user')['logons'].agg(
    med='median',
    mad=lambda s: (s - s.median()).abs().median(),
    buckets='size')

hourly = hourly.join(prof, on='target_user')

# Iglewicz and Hoaglin modified z-score; the usual cutoff is 3.5
hourly['mz'] = 0.6745 * (hourly['logons'] - hourly['med']) / hourly['mad']
hourly.loc[hourly['mad'] == 0, 'mz'] = np.nan

Two things bite in production. Accounts with a MAD of zero, which is every service account that authenticates an identical number of times each hour, divide by zero and yield inf rather than an error, so pandas will happily rank them at the top of your queue forever. Set them to NaN and cover them with a field-value rule. Accounts with fewer than about 336 hourly buckets (two weeks) have no baseline worth using; score those against a peer group from the same organizational unit until they accumulate one.

Rarity carries more signal than magnitude

Most real findings in auth data are not “more logons than usual.” They are “this account has never done this before.”

Microsoft’s own monitoring recommendations on that same 4624 page read like a rarity model rather than a statistical one: watch for a logon type that does not match the account type, such as Batch or Service used by a member of a domain admin group, and for a service account authenticating from a source address outside its expected set. No distribution fitting involved, only a record of what each account has done before.

cut = auth['ts'].max() - pd.Timedelta(days=1)
hist = auth[auth['ts'] < cut]
recent = auth[auth['ts'] >= cut].copy()

known = hist.groupby('target_user').agg(
    hosts=('host', lambda s: set(s)),
    types=('logon_type', lambda s: set(s)),
    subnets=('src_ip', lambda s: set(s.str.rsplit('.', n=1).str[0])))

def novelty(row):
    if row['target_user'] not in known.index:
        return ['no_history']
    p = known.loc[row['target_user']]
    out = []
    if row['host'] not in p['hosts']:
        out.append('new_host')
    if row['logon_type'] not in p['types']:
        out.append('new_logon_type')
    if row['src_ip'].rsplit('.', 1)[0] not in p['subnets']:
        out.append('new_subnet')
    return out or ['known']

recent['flags'] = recent.apply(novelty, axis=1)

The /24 grouping on source address is crude and will misfire on any network that does not allocate by subnet the way you assume. Check that against your own IPAM before trusting it.

Stack the flags instead of alerting on each. One new host is a laptop refresh. A new host plus a first-seen logon type 9 plus a new subnet in the same hour is an investigation, and type 9 specifically is a common artifact of pass-the-hash (T1550.002).

What a per-account baseline cannot see

Password spraying is the clean example. In T1110.003 one source tries a couple of passwords against hundreds of accounts, which is one extra 4625 per account per hour. Every per-user model on earth ignores it. The detection is a different grouping key, not a better model:

spray = (auth[auth['event_id'] == 4625]
         .groupby(['src_ip', 'hour'])['target_user']
         .nunique())

Two more worth writing down before anyone trusts the output. Kerberoasting (T1558.003) lives in 4769 rather than 4624, and the signal is a run of service ticket requests with encryption type 0x17 (RC4-HMAC) in a domain that otherwise issues 0x12: a field-value rule, not an outlier score. And an attacker authenticating as a real user, from that user’s own workstation, during that user’s normal hours raises no novelty flag and no count anomaly. Valid accounts (T1078) is the technique this entire approach handles worst, and tuning does not fix it.

Where to practice

Open Threat Research publishes recorded Windows event data from simulated attacks in Security-Datasets, which is a better place to test the code above than production.

We teach this exact progression, global model to per-account baseline to rarity flags to the pivot in grouping key, in the anomaly detection block of Threat Hunting with Data Science. Organization-specific model creation is a topic in that course for a practical reason: a baseline built on somebody else’s domain does not transfer to yours, and the tuning work is where the detection actually gets built. The same material shows up in the machine learning for threat hunters track.

Frequently Asked Questions

Why does a single anomaly detection model perform badly on authentication logs?
Because authentication volume per account spans several orders of magnitude. A backup service account may authenticate thousands of times an hour while a director logs in twice a day. A model fitted across all accounts learns a mixture distribution, and both tails of that mixture sit permanently in the anomalous region. The result is an alert queue listing your most unusual accounts rather than your compromised ones, and it looks the same every day because nothing about those accounts has changed. The unit of normal in authentication data is the account, not the population.
Should I use mean and standard deviation or median and MAD for auth log baselines?
Median and median absolute deviation (MAD). Both mean and standard deviation are pulled by the very events you are hunting: one burst of activity raises the mean and inflates the standard deviation, which raises your threshold and hides the next burst. The median and MAD barely move. The usual convention is the Iglewicz and Hoaglin modified z-score, 0.6745 * (x - median) / MAD, flagged above 3.5. Watch for accounts whose MAD is zero, which happens with scheduled service logons that fire an identical number of times every hour. Those produce a divide by zero rather than an error, so exclude them and cover them with a field-value rule instead.
How much authentication history do I need before a per-user baseline is useful?
Roughly two weeks per account as a floor, and 30 days if you want the model to have seen a full patch and payroll cycle. Below that, weekly periodicity is not represented and Monday and month-end activity reads as anomalous. Accounts that fall under the threshold, including every new hire and every freshly provisioned service account, should be scored against a peer group (same organizational unit or job function) until they accumulate their own history. Do not leave them unscored: a newly created account with no baseline is exactly the situation an attacker who provisions their own account is counting on.
Which Windows event IDs do I need for authentication anomaly detection?
4624 (successful logon) and 4625 (failed logon) from the endpoint and domain controller Security logs, plus 4768 and 4769 for Kerberos ticket activity and 4648 for logons using explicit credentials. The field that carries the most meaning is Logon Type on 4624, because without it you cannot distinguish an interactive desktop session (type 2) from a network logon (type 3), an RDP session (type 10), or the NewCredentials logon (type 9) that runas /netonly produces. Collect the target account, host, logon type, source network address, and authentication package at minimum.
Can anomaly detection on auth logs detect password spraying?
Not with a per-account baseline, and this is the most useful limitation to understand. Password spraying (MITRE ATT&CK T1110.003) tries one or two passwords against hundreds of accounts, so each individual account records one extra failed logon in an hour, comfortably inside any sensible baseline. The detection requires changing the key you aggregate on: group 4625 events by source address and time window, then count distinct target accounts. Spraying is obvious per source and invisible per user, which is a general lesson about anomaly detection. The grouping key determines what you can see.

Related posts

Want to learn more?

Explore our hands-on AI and cybersecurity training courses.

View Courses