---
title: 'Authentication Logs'
source: 'https://academia.sh/en/courses/endpoint-security/authentication-logs'
course: 'Endpoint and System Hardening'
language: en
updated: '2026-09-26T03:02:51+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Parses a text-format authentication log with a regular expression and counts failure clusters'
    - 'Measures a threshold choice in two directions: missed campaign events versus false positives and locked-out legitimate accounts'
    - 'Shows why a rule that counts per account never sees a spray-shaped attempt, and compares it with a rule that counts along the zone axis'
---

# Authentication Logs

A one-day, 1,168-line fictional authentication log set is parsed and six failure clusters are measured against threshold rules: the per-account threshold misses 206 of 216 campaign events, produces 5 false positives, and blocks 9 legitimate logins; opening the window to 24 hours catches both campaigns but locks out 70 accounts and cuts off 49 legitimate logins. Because the spray never makes more than 2 attempts per account, it is invisible at any threshold; a rule that counts along the zone axis separates it out, because the largest legitimate bulk failure stays at 62 accounts.

In the previous lesson's coverage table, the authentication log did not write a single one of the
twelve foothold point types by name. That source's job is not tracking items: a foothold point
has to have a session opened before it can be set up, and the authentication log sees exactly
that step. The number of points it closes is zero; what it makes visible is the step **before**
the points.

This lesson takes that log on its own. The question is not how to write an alert rule, but what a
chosen threshold misses and how much legitimate work it stops in exchange. The log set below is a
**model** and the regional measurement network is **fictional**; account names, machine names, and
zone names are made up. **Attempting passwords against a set of accounts without written
authorization and a defined scope is both illegal and outside professional bounds**; what is
measured here is not the attempt itself, but the record it leaves behind.

- **SL6.** The log set covers one day: 96 personnel accounts, 14 service identities, five network
  zones. The lines are text; fields are extracted with a regular expression.
- **SL7.** The cluster label the model knows is **not written** to the log line. The parser cannot
  see it; the rules only have the fields present in the line.
- **SL8.** There are six clusters. Four are legitimate: a typo, a forgotten password, a service
  account with a stale credential, and a server-side authentication setting change. Two are
  campaigns: a targeted attempt and a spray.
- **SL9.** Rule 1 counts failures per account, rule 2 counts **distinct accounts** per zone. Both
  use a sliding window; for a given key, only the first crossing produces an alert.
- **SL10.** The threshold is tied to lockout: an account that crosses the threshold cannot log in
  for the rest of the day. The blocked-login count follows from this assumption.

## Parsing the Log

```js
// login.mjs — a fictional authentication log is generated and parsed. THIS IS A MODEL.
// There is no real organization, real account, or real address; zone names are fictional.
// The generator is written by hand; the seed is visible below.
export const SEED = 20260314;
const generator = (t) => { let s = t >>> 0;
  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; };
const r = generator(SEED);
const int = (n) => Math.floor(r() * n);
const clock = (s) => [s / 3600, (s / 60) % 60, s % 60]
  .map((v) => String(Math.floor(v)).padStart(2, "0")).join(":");

export const ACCOUNT = Array.from({ length: 96 }, (_, i) => `sh-${101 + i}`);
export const SERVICE = Array.from({ length: 14 }, (_, i) => `sv-${201 + i}`);
export const ZONE = ["branch-net", "field-vpn", "collector-net", "management-net", "external-access"];

// Raw record: [seconds, machine, result, account, zone, label]. The label is the ground truth
// the model knows and is NOT WRITTEN to the log line; parsing cannot see it.
const raw = [];
const add = (s, mac, result, account, zone, label) =>
  raw.push([s, mac, result, account, zone, label]);

// 1) Normal login at shift start; a fraction has a typo.
for (const [i, h] of ACCOUNT.entries()) {
  const z = ZONE[i % 3], mac = `ep-${String(1 + (i % 268)).padStart(3, "0")}`;
  let t = 25200 + int(7200);                       // between 07:00 and 09:00
  if (r() < 0.22) for (let k = 0, n = 1 + int(2); k < n; k++)
    add(t += 20 + int(40), mac, "failed", h, z, "typo");
  add(t + 15, mac, "succeeded", h, z, "typo");
  for (let k = 0, n = int(3); k < n; k++)
    add(36000 + int(28800), mac, "succeeded", h, z, "typo");
}
// 2) Three accounts that forgot their password: heavy failures, then a reset and login.
for (const h of [ACCOUNT[7], ACCOUNT[41], ACCOUNT[70]]) {
  let t = 27000 + int(9000);
  const n = 9 + int(8);
  for (let k = 0; k < n; k++)
    add(t += 30 + int(90), "ep-114", "failed", h, "field-vpn", "forgotten-password");
  add(t + 2400, "ep-114", "succeeded", h, "field-vpn", "forgotten-password");
}
// 3) Two service accounts with stale credentials: steady retries all day.
for (const [j, h] of [SERVICE[3], SERVICE[9]].entries())
  for (let t = 600 + j * 130; t < 86400; t += 300)
    add(t, "ep-207", "failed", h, "collector-net", "stale-service-credential");
// 4) A targeted attempt against one account: the same account for three hours.
for (let k = 0, t = 68400; k < 40; k++)
  add(t += 240 + int(60), "ep-018", "failed", ACCOUNT[92], "external-access", "targeted-attempt");
// 5) Spray: two attempts against each of 88 accounts, a fifty-minute window.
for (const [j, h] of ACCOUNT.slice(0, 88).entries()) {
  add(48000 + j * 17, "ep-018", "failed", h, "external-access", "spray");
  add(49500 + j * 17, "ep-018", "failed", h, "external-access", "spray");
}
add(51100, "ep-018", "succeeded", ACCOUNT[63], "external-access", "spray");
// 6) Server-side authentication setting change: 62 branch accounts fail once, then log in.
for (const [j, h] of ACCOUNT.slice(20, 82).entries()) {
  add(36000 + j * 19, "ep-061", "failed", h, "branch-net", "config-change");
  add(36300 + j * 19, "ep-061", "succeeded", h, "branch-net", "config-change");
}

raw.sort((a, b) => a[0] - b[0]);
export const LINE = raw.map(([s, mac, result, account, zone]) =>
  `2026-03-14T${clock(s)} ${mac} authentication result=${result} account=${account} ` +
  `zone=${zone} method=password`);
export const LABEL = raw.map((k) => k[5]);

// Parsing: the log line is text, its fields are extracted with a regular expression.
const PATTERN = /^(\S+)T(\S+) (\S+) authentication result=(\w+) account=(\S+) zone=(\S+) /;
export const parse = (line) => {
  const m = PATTERN.exec(line);
  if (!m) return null;
  const [, , ti, machine, result, account, zone] = m;
  const [h, d, s] = ti.split(":").map(Number);
  return { t: h * 3600 + d * 60 + s, machine, result, account, zone };
};
export const EVENT = LINE.map(parse);

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log(`model (seed ${SEED}): ${LINE.length} log lines, ` +
    `${ACCOUNT.length} personnel and ${SERVICE.length} service accounts (fictional)`);
  for (const i of [0, 1]) console.log(LINE[i]);
  const broken = EVENT.filter((o) => o === null).length;
  const fail = EVENT.filter((o) => o.result === "failed");
  console.log(`\nunparsable lines ${broken}; failed ${fail.length}, ` +
    `succeeded ${EVENT.length - fail.length}`);
  const row = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j])
    : String(v).padEnd(B[0]))).join("");
  const line1 = row([26, 12, 10, 10, 17]);
  console.log(line1(["cluster (ground truth)", "failed", "accounts", "zones",
    "max per account"]));
  for (const e of [...new Set(LABEL)]) {
    const l = LINE.map((_, i) => i).filter((i) => LABEL[i] === e &&
      EVENT[i].result === "failed");
    const h = {};
    for (const i of l) h[EVENT[i].account] = (h[EVENT[i].account] || 0) + 1;
    console.log(line1([e, l.length, Object.keys(h).length,
      new Set(l.map((i) => EVENT[i].zone)).size, Math.max(0, ...Object.values(h))]));
  }
}
```

```
model (seed 20260314): 1168 log lines, 96 personnel and 14 service accounts (fictional)
2026-03-14T00:10:00 ep-207 authentication result=failed account=sv-204 zone=collector-net method=password
2026-03-14T00:12:10 ep-207 authentication result=failed account=sv-210 zone=collector-net method=password

unparsable lines 0; failed 916, succeeded 252
cluster (ground truth)          failed  accounts     zones  max per account
stale-service-credential           572         2         1              286
typo                                29        19         3                2
forgotten-password                  37         3         1               14
config-change                       62        62         1                1
spray                              176        88         1                2
targeted-attempt                    40         1         1               40
```

The cluster table puts the whole tension of the lesson on a single screen. **572 of the day's 916
failed logins come from a single problem**: two service accounts with stale credentials retry
every five minutes and produce 286 failures per account. That number is seven times the 40
failures the deepest campaign leaves on a single account, and it is entirely legitimate.

The shapes of the two campaigns in the bottom rows are opposites. The targeted attempt leaves 40
failures on a single account — deep and narrow. The spray spreads across 88 accounts but makes
**only two attempts per account** — shallow and wide. The fourth row shows why the second shape is
hard to separate: a server-side setting change produced one failure each on 62 accounts, and its
shape resembles a spray.

## Threshold Per Account

```js
// threshold.mjs — threshold choice: missed events, needless alerts, and legitimate work lockout stops.
import { EVENT, LABEL, LINE } from "./login.mjs";

const CAMPAIGN = ["targeted-attempt", "spray"];
const failures = [];
EVENT.forEach((o, i) => { if (o.result === "failed")
  failures.push({ ...o, label: LABEL[i], id: failures.length }); });
const succeeded = EVENT.map((o, i) => ({ ...o, label: LABEL[i] }))
  .filter((o) => o.result === "succeeded");
const campaignEvents = failures.filter((o) => CAMPAIGN.includes(o.label));

// For one key (account or zone) in a sliding window: the moment it first crosses the threshold, and the events in that window.
function crossing(key, measure, threshold, window) {
  const first = new Map();
  for (const [i, o] of failures.entries()) {
    const k = key(o);
    if (first.has(k)) continue;
    const w = failures.slice(0, i + 1).filter((x) => key(x) === k && x.t > o.t - window);
    if (measure(w) >= threshold) first.set(k, { t: o.t, w });
  }
  return first;
}
const count = (w) => w.length;
const distinctAccounts = (w) => new Set(w.map((x) => x.account)).size;

// Evaluation looks at the alert's WINDOW: does it contain a campaign event.
function evaluate(alerts) {
  const covered = new Set();
  let empty = 0;
  for (const [, v] of alerts) {
    const c = v.w.filter((o) => CAMPAIGN.includes(o.label));
    if (c.length === 0) empty++;
    for (const o of c) covered.add(o.id);
  }
  const caught = new Set(campaignEvents.filter((o) => covered.has(o.id))
    .map((o) => o.label));
  return { caught: caught.size, missed: campaignEvents.length - covered.size, empty };
}

const row = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j])
  : String(v).padEnd(B[0]))).join("");
const line1 = row([10, 11, 12, 12, 15, 12, 19]);
const line2 = row([11, 8, 12, 15, 12, 19]);

console.log(`${LINE.length} lines, ${failures.length} failed logins; two campaigns ` +
  `(${CAMPAIGN.join(", ")}) produced ${campaignEvents.length} events in total`);
console.log("\nrule 1 — failure count per account");
console.log(line1(["window", "threshold", "locked out", "caught", "missed events",
  "false pos.", "blocked logins"]));
for (const [name, window] of [["1 hour", 3600], ["24 hours", 86400]])
  for (const threshold of [3, 5, 10, 20]) {
    const alerts = crossing((o) => o.account, count, threshold, window);
    const d = evaluate(alerts);
    const blocked = [...alerts].reduce((a, [k, v]) => a + succeeded
      .filter((b) => b.account === k && b.t >= v.t && b.label !== "spray").length, 0);
    console.log(line1([name, threshold, alerts.size, `${d.caught}/2`, d.missed, d.empty, blocked]));
  }

console.log("\nrule 2 — count of DISTINCT accounts per zone (1-hour window)");
console.log(line2(["threshold", "alerts", "caught", "missed events", "false pos.", "blocked accounts"]));
for (const threshold of [10, 20, 40, 70, 90]) {
  const alerts = crossing((o) => o.zone, distinctAccounts, threshold, 3600);
  const d = evaluate(alerts);
  const blockedAccounts = new Set([...alerts].filter(([, v]) => !v.w.some((o) =>
    CAMPAIGN.includes(o.label))).flatMap(([, v]) => v.w.map((o) => o.account))).size;
  console.log(line2([threshold, alerts.size, `${d.caught}/2`, d.missed, d.empty, blockedAccounts]));
}

const biggestLegit = Math.max(...failures.map((o, i) => new Set(failures.slice(0, i + 1)
  .filter((x) => x.zone === o.zone && x.t > o.t - 3600 && !CAMPAIGN.includes(x.label))
  .map((x) => x.account)).size));
const spray = failures.filter((o) => o.label === "spray");
const perAccount = [...new Set(spray.map((o) => o.account))]
  .map((h) => spray.filter((o) => o.account === h).length);
console.log(`\nspray: ${new Set(spray.map((o) => o.account)).size} accounts, at most ` +
  `${Math.max(...perAccount)} attempts per account, 1 successful login`);
console.log(`largest LEGITIMATE bulk failure: ${biggestLegit} distinct accounts in one hour`);
```

```
1168 lines, 916 failed logins; two campaigns (targeted-attempt, spray) produced 216 events in total

rule 1 — failure count per account
window      threshold  locked out      caught  missed events  false pos.     blocked logins
1 hour              3           6         1/2            213           5                  9
1 hour              5           6         1/2            211           5                  9
1 hour             10           6         1/2            206           5                  9
1 hour             20           0         0/2            216           0                  0
24 hours            3          70         2/2            104          11                 49
24 hours            5          12         2/2            199           5                 16
24 hours           10           6         1/2            206           5                  9
24 hours           20           3         1/2            196           2                  0

rule 2 — count of DISTINCT accounts per zone (1-hour window)
threshold    alerts      caught  missed events  false pos.   blocked accounts
10                2         1/2            206           1                 10
20                2         1/2            196           1                 20
40                2         1/2            176           1                 40
70                1         1/2            146           0                  0
90                0         0/2            216           0                  0

spray: 88 accounts, at most 2 attempts per account, 1 successful login
largest LEGITIMATE bulk failure: 62 distinct accounts in one hour
```

The first table's first three rows try three different thresholds and **all three give the same
result**: six accounts get locked out, one of the two campaigns is caught, and between 206 and
213 of the 216 campaign events get missed. There is no measurable detection difference among
thresholds set at three, five, and ten; all three catch the same five legitimate accounts, because
the only things that cross ten failures in a one-hour window are stale service credentials, users
who forgot their password, and the targeted attempt.

The table's `false pos.` column counts alerts whose window contains no campaign event — that is,
**false positives**: at every threshold, five alerts come from a legitimate cause, and if lockout
is on, nine legitimate logins are stopped. Two are service accounts; what stops when they get
locked out is not a user, it is a meter-collection job. Pulling the threshold up to twenty
zeroes out detection along with the false positives: zero campaigns caught, 216 events missed.

Opening the window to twenty-four hours changes the table but does not solve the problem. At a
threshold of three, both campaigns get caught and missed events drop to 104; the cost is **70
locked-out accounts**, 11 false positives, and 49 blocked legitimate logins. That means everyone
who mistypes their password once during the day cannot log back in until evening. That is the
bill for buying sensitivity from the window.

How the catch happens matters too: the spray becomes visible only because its two attempts per
account **add up** with a legitimate failure elsewhere in the day. The rule does not see it as a
spray; it sees an account that has accumulated three failures, and on accounts with no legitimate
failure at all, it still does not see it.

## Counting the Same Event Along the Zone Axis

A rule that counts per account can never see the spray on its own at any threshold, because it is
measuring the wrong quantity: the campaign stays at two attempts per account, and **two** is below
the level of a legitimate typo. Lowering the threshold does not help here — it only turns typos
into alerts. What needs to change is not the threshold, it is the **key** being counted.

The second table reads the same log by counting distinct accounts per zone. At thresholds of ten,
twenty, and forty, two alerts come out, and one is a false positive: the server-side setting
change produced one failure each on 62 branch accounts, and this rule sees it as a campaign. In a
rule tied to zone blocking, the cost reads directly — at a threshold of forty, 40 legitimate
accounts get blocked.

At a threshold of seventy, only one alert remains, the false positive drops to zero, and the spray
is separated out. This row looks promising, but the last output line writes the limit: **the
largest legitimate bulk failure is 62 accounts in one hour.** The rule can only separate out
attempts that spread wider than that without producing a false positive. If the same campaign had
spread across 50 accounts, it could not have been separated at any threshold; and at a threshold
of ninety, even this campaign is missed and all 216 events become invisible.

Translated into this course's measure, the table says: the authentication log **closes no
foothold point at all**; it only makes visible the step before a point is set up, and that
visibility depends on the threshold. The one successful login the spray produced is sitting right
there in the record too, but a failure threshold never looks at successful logins at all.

## Summary

- 572 of the day's 916 failed logins come from a single legitimate problem: two service accounts
  with stale credentials produce 286 failures per account, seven times the 40 failures the deepest
  campaign leaves on a single account.
- A rule that counts per account locks out the same six accounts in a one-hour window at
  thresholds of three, five, and ten — none at a threshold of twenty; at a threshold of ten, 206
  of 216 campaign events get missed, 5 alerts are false positives, and 9 legitimate logins are
  blocked.
- Opening the window to 24 hours catches both campaigns and brings missed events down to 104; the
  cost is 70 locked-out accounts, 11 false positives, and 49 blocked legitimate logins.
- Because the spray makes at most 2 attempts per account, it is invisible at any account-axis
  threshold; the number two is below the level of a legitimate typo, and lowering the threshold
  only turns typos into alerts.
- A rule that counts distinct accounts per zone separates the spray out without a false positive
  at a threshold of 70; the floor for that is the size of the largest legitimate bulk failure — 62
  accounts in one hour.

## Next Step

Both rules looked at a single field: whether the login failed, on which account, in which zone.
What happens after a session opens is not in this log; the one successful login the spray produced
cannot be told apart from the other 251 successful logins. The next lesson looks inside that
session and opens the source that wrote eight types by name on its own in the first lesson's
table: the process creation log. The measure there is parent-process context — how many items
look suspicious when the same command list is read as a flat sequence, how many get separated out
when it is read as a tree, and what the record's volume is.
