Deep dive
How it works
Parts:
- Collector — captures timestamped key, pointer or touch events on the client; ideally extracts features there and sends only those.
- Feature extractor — dwell and flight per key pair; pointer speed, acceleration, curvature, pauses and overshoot per movement; pressure and area for touch; gaps and regularity of in-app actions.
- Profile store — per user and context (device, keyboard, fixed text or free text): mean and spread per feature, sample count, last update.
- Scorer — distance of a new sample to the profile, plus a liveness check on how much a window of samples varies.
- Policy — thresholds that turn scores into allow, step-up or review, usually inside a wider bot-detection or fraud score.
- Enrol: collect N genuine samples of the same task (a typed phrase, a login form, a recurring gesture). Per feature store the mean
muand the mean absolute deviationmad. - For each new sample compute
d = (1/n) · Σ |x_i − mu_i| / mad_i. A genuine sample scores near 1 because each term has about unit scale. - Liveness: over a short window of samples compute each feature's spread relative to the enrolled
mad. A ratio far below 1 means machine-regular input, as a human is never that consistent. - Pick a threshold from genuine and impostor score distributions; sweep it to see FAR and FRR and the EER.
- Combine:
d, the liveness flag, and non-behavioural signals (device fingerprint, posture, IP reputation) enter one weighted score. - Act in grades: allow, log, step-up (a second factor), then review. Do not block on this signal alone.
- Update: after a confirmed-genuine session, move
muandmadtoward the new sample (exponential smoothing); re-enrol when drift is persistent or the device changes.
events ──► features ──► d = mean |x−mu|/mad ─┐
(client) (on device) window spread ──────┼──► risk score ──► allow | step-up | review
device/IP/posture ──┘
profile {mu, mad, n} ◄── update after confirmed-genuine sessions
Scaled Manhattan distance is a simple detector; Killourhy and Maxion compare anomaly detectors on timing data from 51 typists (benchmark page below). Error rates depend on the data and protocol, so measure on yours.
Code
use std::hint::black_box;
const N: usize = 21; // fixed phrase of 11 keys: 11 dwell + 10 flight times (ms)
type Sample = [f64; N];
struct Rng(u64); // xorshift64*, fixed seed -> reproducible
impl Rng {
fn u(&mut self) -> f64 {
self.0 ^= self.0 >> 12; self.0 ^= self.0 << 25; self.0 ^= self.0 >> 27;
(self.0.wrapping_mul(0x2545F4914F6CDD1D) >> 11) as f64 / (1u64 << 53) as f64
}
fn gauss(&mut self) -> f64 { // Box-Muller
(-2.0 * (1.0 - self.u()).ln()).sqrt() * (6.2831853 * self.u()).cos()
}
}
struct Profile { mu: Sample, mad: Sample } // per-feature mean and mean absolute deviation
impl Profile {
fn enrol(s: &[Sample]) -> Self {
let (mut mu, mut mad) = ([0.0; N], [0.0; N]);
for i in 0..N { mu[i] = s.iter().map(|x| x[i]).sum::<f64>() / s.len() as f64; }
for i in 0..N { mad[i] = s.iter().map(|x| (x[i] - mu[i]).abs()).sum::<f64>() / s.len() as f64; }
Profile { mu, mad }
}
/// Scaled Manhattan distance: mean of |x - mu| / mad. Genuine samples score about 1.
fn score(&self, x: &Sample) -> f64 {
(0..N).map(|i| (x[i] - self.mu[i]).abs() / self.mad[i]).sum::<f64>() / N as f64
}
/// Liveness: a window of samples whose spread is far below the enrolled spread is machine-regular.
fn too_regular(&self, w: &[Sample]) -> bool {
let r: f64 = (0..N).map(|i| {
let m = w.iter().map(|x| x[i]).sum::<f64>() / w.len() as f64;
w.iter().map(|x| (x[i] - m).abs()).sum::<f64>() / w.len() as f64 / self.mad[i]
}).sum::<f64>() / N as f64;
r < 0.1
}
}
fn typing(r: &mut Rng, mean: &Sample, cv: f64) -> Sample { // timings ~ N(mean, (cv*mean)^2), floor 5 ms
let mut s = [0.0; N];
for i in 0..N { s[i] = (mean[i] * (1.0 + cv * r.gauss())).max(5.0); }
s
}
fn main() {
let mut r = Rng(0x9E3779B97F4A7C15);
let mut base = [0.0; N]; // genuine user's true means: dwell ~60-120 ms, flight ~80-250 ms
for i in 0..N { base[i] = if i < 11 { 60.0 + 60.0 * r.u() } else { 80.0 + 170.0 * r.u() }; }
let enrol: Vec<Sample> = (0..30).map(|_| typing(&mut r, &base, 0.12)).collect();
let p = Profile::enrol(&enrol);
let mut tr: Vec<f64> = enrol.iter().map(|x| p.score(x)).collect();
tr.sort_by(|a, b| a.partial_cmp(b).unwrap());
let thr = tr[28]; // threshold: ~95th percentile of the enrolment scores
let gen: Vec<f64> = (0..2000).map(|_| p.score(&typing(&mut r, &base, 0.12))).collect();
let (mut imp, mut m) = (Vec::new(), base);
for k in 0..2000 { // 40 impostors x 50 attempts; each has own means, ~20 % off the genuine ones
if k % 50 == 0 { m = base; for v in m.iter_mut() { *v *= 1.0 + 0.20 * r.gauss(); } }
imp.push(p.score(&typing(&mut r, &m, 0.12)));
}
let acc = gen.iter().filter(|&&d| d <= thr).count() as f64 / gen.len() as f64;
let rej = imp.iter().filter(|&&d| d > thr).count() as f64 / imp.len() as f64;
let script: Vec<Sample> = (0..5).map(|_| base).collect(); // perfectly regular typist
let human: Vec<Sample> = (0..5).map(|_| typing(&mut r, &base, 0.12)).collect();
let eer = (0..400).map(|k| 0.5 + k as f64 * 0.005).map(|t| {
let frr = gen.iter().filter(|&&d| d > t).count() as f64 / gen.len() as f64;
let far = imp.iter().filter(|&&d| d <= t).count() as f64 / imp.len() as f64;
((far - frr).abs(), (far + frr) / 2.0)
}).fold((9.0, 0.0), |a, b| if b.0 < a.0 { b } else { a }).1;
println!("thr {thr:.2} genuine accepted {acc:.3} impostors rejected {rej:.3} synthetic EER {eer:.3}");
assert!(acc >= 0.90 && rej >= 0.90 && p.too_regular(&script) && !p.too_regular(&human));
let x = typing(&mut r, &base, 0.12);
let t = std::time::Instant::now();
let mut s = 0.0;
for _ in 0..1_000_000 { s += p.score(black_box(&x)); }
println!("{:.1} ns/score ({s:.0})", t.elapsed().as_nanos() as f64 / 1e6);
}
Fixed-seed run on synthetic data (mean timings and noise drawn from Gaussians I chose; not measured human data): the threshold at the 95th percentile of the 30 enrolment scores accepts 93.2 % of 2,000 fresh genuine samples and rejects 98.8 % of 2,000 attempts by 40 impostors whose means sit about 20 % from the owner's; the scripted typist is flagged by the spread check and a human window is not; sweeping the threshold gives a synthetic EER of 2.3 %. Real impostors, devices and sessions are far less separable, so do not transfer this EER.
Complexity and constants
- Score: O(n) for n features. Measured: about 7.6 ns per score at n = 21 (Apple Silicon, release build, 1,000,000 scores of one sample, so everything is in cache).
- Profile: 2n floats, 336 bytes at n = 21 in
f64, plus a count and timestamp. - Event capture is the real cost: pointer events arrive at the display or sensor rate, order of tens to hundreds per second while moving. Reduce to features before they leave the device.
Efficiency
Cost: scoring is nanoseconds (measured above); capture and feature extraction on the client dominate, and uploading raw event streams dominates the network cost. Bound by event volume, not by arithmetic.
Levers:
- on-device feature extraction — sends tens of numbers per session instead of event logs (design choice) — whenever the client is code you ship.
- fixed-text or per-form profiles — fewer samples to enrol (design choice, not measured here) — logins, step-up prompts.
- score only at decision points — login, payment, high-value action — instead of every event — to keep CPU and battery low.
- shared risk score — one cheap
djoins existing scoring — when a pipeline like bot detection exists.
Naive → standard → state of the art:
- store raw event logs and compare in the backend · bytes per session in the kilobytes, privacy risk
- per-user mean and spread, scaled distance, threshold · nanoseconds per score, a few hundred bytes of state
- learned models over sequences, per-device profiles, continuous updating · training and inference cost, better accuracy where data is plentiful
Implementation notes
- Use
performance.now()-style monotonic timestamps; coarse clocks blur dwell and flight times (Killourhy and Maxion studied clock resolution in a separate paper). - Key by task and context: a laptop keyboard and a phone are different profiles for one person; free text needs far more samples than a fixed phrase.
- Trim long pauses and outliers before computing
mad; one distracted second can dominate a mean. - Calibrate the final risk with calibration so a score means the same error rate over time; fit weights with logistic regression.
- Store features, not raw key content; never log what was typed.
Pitfalls
- Accessibility: assistive technology, tremor, one-handed typing and voice control produce atypical or very regular input. Never block on this signal alone; keep an alternative path and an appeal.
- Drift: injuries, fatigue, new keyboards, mobile versus desktop. A fixed threshold slowly raises FRR.
- A threshold tuned on average impostors says little about someone who has watched the owner type.
- Attackers adapt and the signal decays; use it as one term among several and keep re-measuring.
- Privacy: GDPR Article 4(14) defines biometric data as personal data from technical processing of physical, physiological or behavioural characteristics that allow or confirm unique identification, and Article 9(1) prohibits processing biometric data for the purpose of uniquely identifying a person unless an Article 9(2) condition applies, for example explicit consent. Whether a given timing profile falls under it depends on use and jurisdiction: ask counsel. Minimise: on-device features, a short retention period, no raw text, a documented purpose, and consent or another recorded basis.
- Shared accounts: two people behind one login average into a profile that matches neither.
Variants and alternatives
In production
Fraud teams at banks and payment providers weigh input behaviour in step-up decisions; learning and exam services check identity continuity across a session; game operators watch timing regularity of moves; enterprise access products offer continuous authentication. Vendors rarely publish accuracy: treat marketing numbers as unverified.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| CMU keystroke dynamics benchmark | data + scripts | project page | download from the page | — | — | — | — | timing data for 51 typists, an evaluation script for 3 detectors and results (Killourhy and Maxion); a benchmark, not a library |
| Balabit Mouse Dynamics Challenge | dataset | balabit/Mouse-Dynamics-Challenge | git clone https://github.com/balabit/Mouse-Dynamics-Challenge | — | — | — | — | recorded pointer sessions for continuous-authentication research; data only |
| I found no widely used maintained open-source verifier; the rows are benchmarks to test your own code against. |