Home › Security › Abuse & bot defense
algorithm medium

Behavioral biometrics

Problem A service sees only streams of keystrokes, pointer moves or touches from a client, and must judge whether a person or a script produced them, or whether it is still the account owner typing.

Build a per-user profile of input timing and motion, score each new session by distance to it, and feed the result into a risk score instead of a verdict.

O(features) per score, a few ns for about 20 features; a few hundred bytes of profile per user
keystroke-dynamicscontinuous-authenticationanomaly-detectionrisk-scoreprivacy
Overview

People type, move a pointer and touch screens with stable habits: how long a key is held, the gap before the next, how a cursor accelerates and overshoots. Keep per-user statistics of these timings and score each new sample by its distance from them. The same features answer two questions: is the input live (a person), and is it the enrolled owner? The score is weak evidence for a risk score, never a lone gate.

Key facts

Use when

  • Banking and payments: step up to a one-time code when typing or pointer use differs from the owner's.
  • Games and trading: input timing of moves or orders that is too regular for a person points to automation.
  • E-learning and exam proctoring: check that the person who started a session is still the one typing.
  • Workforce access, forms and sign-ups: keep a session alive silently while behaviour matches, or separate people from scripted submissions.

Avoid when

  • It would be the only reason to block someone.
  • Input comes through assistive technology, voice control, a shared kiosk or a changing device mix.
  • Data volume per user is tiny or you cannot document consent or another legal basis.

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.
  1. Enrol: collect N genuine samples of the same task (a typed phrase, a login form, a recurring gesture). Per feature store the mean mu and the mean absolute deviation mad.
  2. 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.
  3. 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.
  4. Pick a threshold from genuine and impostor score distributions; sweep it to see FAR and FRR and the EER.
  5. Combine: d, the liveness flag, and non-behavioural signals (device fingerprint, posture, IP reputation) enter one weighted score.
  6. Act in grades: allow, log, step-up (a second factor), then review. Do not block on this signal alone.
  7. Update: after a confirmed-genuine session, move mu and mad toward 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 d joins 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

Free-text profiles and sequence modelsbuilt from natural typing or learned from event streams; need more data, fit long sessions, harder to audit.
Device fingerprintingidentifies the machine, not the person's habits; cheaper, complementary.Proof-of-work challengesmake automation cost compute instead of judging behaviour; no personal data.
Possession and knowledge factorshardware keys, passkeys and codes verify the account owner more reliably; behaviour decides only when to ask.

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

namelangrepoinstallverdictlicencehealthas_ofnotes
CMU keystroke dynamics benchmarkdata + scriptsproject pagedownload 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 Challengedatasetbalabit/Mouse-Dynamics-Challengegit 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.

Sources

  • Killourhy and Maxion, Comparing anomaly-detection algorithms for keystroke dynamics, IEEE/IFIP DSN 2009. paper · data
  • Regulation (EU) 2016/679 (GDPR), Articles 4(14) and 9. EUR-Lex

Builds on

Used by

Systems (3):

Alternatives and related

Device fingerprintingA signup, login or checkout service must recognise that a request comes from a device it has seen before, even when cookies are cleared or accounts differ, to link accounts, catch takeovers and throttle mass signups.Reputation scoringAn online service sees senders, sellers, accounts, IPs, domains and URLs it has no history with, and must decide how far to trust each one from a few observed good and bad events, and act on that.
device posture checks · plannedCollecting and verifying device health (OS version, disk encryption, EDR running, managed cert) before granting access
proof of work challenge · plannedClient puzzles (hashcash-style) that cost a client CPU to raise abuse cost, with adjustable difficulty
CalibrationA trained classifier's confidence scores are over- or under-confident, so a "90 %" prediction is right far less or more than 90 % of the time. This matters when probabilities drive thresholds, costs or search decisions.Exponential smoothing (ETS, Holt-Winters)Teams forecasting many series (per product, user or endpoint) with trend and seasonality need a cheap, explainable forecast with tiny state that updates online, and a baseline that fancier models must beat.
time decay scoring · plannedScoring items by recency with exponential decay or half-life, combined with relevance
Linear and logistic regressionA data team wants a simple, fast and interpretable model that predicts a number or a probability from features, and a baseline that any fancier model must beat to justify its complexity.