The Human Signal Is (Nearly) Impossible to Fake by Hand#

Mouse paths, keystroke timing, scroll velocity, gaze, and dwell time are the final frontier of bot detection. A WAF's machine-learning model has seen millions of human sessions; it knows the statistical shape of human interaction. Perfectly consistent, perfectly efficient automation is statistically impossible to confuse with that shape. The only way to pass is to model the human shape statistically.

Mouse Movement Modeling#

Humans do not move in straight lines. A mouse path is curved, accelerates, overshoots the target, hesitates, then lands. The standard model is a Bézier curve with an overshoot-and-correct phase for the final approach. Libraries like humanize-js (Node) and Python's mouse automation packages implement these curves. Implement phase timing too: the entire path should take a variable 400–900ms, not a fixed 500ms.

def bezier_mouse(from_x, from_y, to_x, to_y, steps=40):
    # Approximate a human-ish curved path between two points
    points = []
    for i in range(steps + 1):
        t = i / steps
        # simple quadratic curve with a mid-point control offset
        cx = (from_x + to_x) / 2 + random.randint(-30, 30)
        x = (1 - t) ** 2 * from_x + 2 * (1 - t) * t * cx + t ** 2 * to_x
        y = (1 - t) ** 2 * from_y + 2 * (1 - t) * t * random.randint(-20, 20) + t ** 2 * to_y
        points.append((round(x), round(y)))
    return points

Keystroke Dynamics#

Typists have a characteristic inter-keystroke interval (IKI) distribution and a signature pattern for pause-after-capitals and pauses mid-word. Hardcode a jittered, biased distribution instead of a flat random tick: fast bursts around 60–110ms between common letter pairs, longer pauses before capitals and after punctuation.

Scroll Behavior#

Humans scroll in bursts: a fast fling, a hesitation, a slower reading scroll, a small corrective nudge. They rarely scroll at constant velocity. Segment your scrolls into "scan" (fast) and "read" (slow) phases with random pauses, and vary the distance scrolled per burst rather than always reaching the bottom instantly.

Timing the Page, Not Just the Input#

Human behavior is not confined to mouse and keyboard. Sessions have a temporal envelope that WAFs model explicitly: the time between page load and first interaction, the dwell time before scrolling, the pause before submitting a form, and the total session length. A user who loads a page and clicks a button 40 milliseconds later did not read the page. A session that lasts exactly nine seconds is a bot's signature.

Build a timing budget into every page visit: a plausible read time proportional to the amount of text on the page (a 2,000-word article warrants 20–60 seconds), a hesitation before clicks near destructive buttons, and a random idle state if the workflow allows. These dwell times are cheap to inject and enormously effective, because WAFs weight temporal signals heavily when the pointer telemetry is synthetic.

The Danger of Over-Perfection#

The deepest irony of biometrics evading is that over-optimized "human" simulation looks more machine-like than a genuinely imperfect simulation. The fix is statistical noise at every layer: everything from the timing between mutations to the exact pixel jerk on a Bézier curve. When in doubt, add more low-amplitude Gaussian noise to every value a real human would wobble on.

Validate on Real Sensor Traces#

If you have access to any human session telemetry (from your own products, or a public dataset), train your generator to match its distributions rather than inventing curves by hand. The best keystroke and mouse models in the wild are fitted to real traces: pairs of consecutive keypresses, path curvature statistics, and dwell-time histograms. Simulation should aim to be statistically plausible, not geometrically pretty—a too-smooth curve is as suspicious as no curve at all.

The Inventory, by Cost to Produce#

Biometric signals differ from fingerprint signals in one important way: a fingerprint is a value you return, and a biometric is a process you must actually simulate. Here is what is available and what each one costs an adversary to fake.

Signal Sample rate available What it grades Cost to simulate
Inter-key interval one per keystroke motor timing, typing consistency medium — needs a fitted distribution
Digraph timing one per adjacent key pair whether the string was typed or pasted low once per-pair timing exists
Key-down to key-up dwell one per keystroke press duration distribution medium — separate distribution again
Pointer velocity and jerk 30-120 Hz hand motor control, device class medium — needs a kinematics model
Pointer path curvature per move ballistic phase structure medium
Click-to-click interval per click rhythm, hesitation low
Scroll velocity profile per frame reading vs skimming low-medium
Scroll direction changes per burst attention model low
Correction behaviour (backspace rate) per keystroke confidence, whether text was copied low but high-signal
Hover dwell before click per element visual search, reading medium
Tab / visibility transitions per transition whether the page was actually looked at very low
Dwell time per field per field comprehension very low
Form abandonment mid-step per step intent very low
Time-to-first-byte vs time-to-first-interaction per page render pipeline vs human latency very low

The bottom half of that table is where most teams should start, because those signals cost almost nothing to generate and most pipelines generate none of them. A scraper that fetches 400 pages with no dwell, no scroll, no hover and no field-level pauses has an empty row in every one of those columns, and "structurally absent" is a stronger signal than "suspiciously uniform".

Why These Resist Fingerprint Spoofing#

A spoofed fingerprint is a value you choose and then hold constant. A biometric is a trajectory that a model can score for physical plausibility, and physical plausibility is not something you choose once — it is constrained by biomechanics, the input device, and the specific text being typed.

Three mechanisms make this different in kind from spoofing navigator.hardwareConcurrency.

Consistency across a heterogeneous session. Human motor output is not stationary. Typing speed depends on the word, position in the sentence, and whether you have typed it before; pointer speed depends on target distance, target width, and whether you know where the target is. A generator that produces one stationary distribution is caught by the variance of the variance — the correlation between gap length and word structure, or between path curvature and distance.

Physical coupling. Acceleration and jerk are bounded by muscle dynamics, and overshoot is proportional to distance and inversely proportional to target size. Path curvature follows a minimum-jerk profile with a distinctive velocity profile that is not a sine wave. A generator that produces geometrically smooth curves with no jerk spike at the transition is flagged by the shape of the acceleration, not by the path.

Conditional dependence on the page. Dwell time correlates with content. A 40-word error message gets 1.5 s; a 2,000-word policy document gets 40 s, and it gets read in bursts with scroll-linked pauses. A generator whose dwell is a fixed function of page type, or worse a constant per URL, is caught when the same profile shows identical dwell on a search results page and a terms-of-service page.

Add the automation-forced constraints on top. Every real browser automation stack either instruments the event dispatch path — which shows up in stack traces and timing, see — or it injects trusted events, which is what the pointer kinematics are specifically calibrated to detect.

Sample Size Is the Binding Constraint#

The reason behavioural defences have a false-positive rate at all is that the evidence is thin. A login form gives you 8 to 20 keystrokes. A search box gives you one. You can compute what an adversary needs from the feature's discriminative power and the sample size actually available:

import math
import random


def gauss(rng, mu=0.0, sigma=1.0):
    u1 = 1.0 - rng.random()
    u2 = rng.random()
    return mu + sigma * math.sqrt(-2.0 * math.log(u1)) * math.cos(2.0 * math.pi * u2)


def human_iki(rng):
    roll = rng.random()
    if roll < 0.62:
        return math.exp(gauss(rng, math.log(0.104), 0.29))
    if roll < 0.81:
        return math.exp(gauss(rng, math.log(0.223), 0.36))
    return math.exp(gauss(rng, math.log(1.42), 0.74))


def synthetic_iki(rng):
    return max(0.022, gauss(rng, 0.146, 0.043))


def quantile(seq, p):
    s = sorted(seq)
    return s[min(len(s) - 1, int(p * len(s)))]


def tail_ratio(seq):
    return quantile(seq, 0.9) / quantile(seq, 0.5)


def median(seq):
    return quantile(seq, 0.5)


def mad(seq):
    m = median(seq)
    return median([abs(v - m) for v in seq])


rng = random.Random(20260903)
CUT = 2.60
SIZES = [4, 8, 16, 32, 64, 128, 256]
TRIALS = 300

print("Separating human IKI from a matched-mean Gaussian on one scale-free feature")
print("")
print("  n    tail ratio real   tail ratio synth   accuracy   95pct lower bound")
rows = []
for n in SIZES:
    correct = 0
    tr_real, tr_fake = [], []
    for _ in range(TRIALS):
        real = [human_iki(rng) for _ in range(n)]
        fake = [synthetic_iki(rng) for _ in range(n)]
        r, f = tail_ratio(real), tail_ratio(fake)
        tr_real.append(r)
        tr_fake.append(f)
        correct += (1 if r >= CUT else 0) + (1 if f < CUT else 0)
    acc = correct / float(2 * TRIALS)
    half = 1.96 * math.sqrt(0.25 / TRIALS)
    rows.append((n, median(tr_real), median(tr_fake), acc, acc - half))
for n, mr, mf, acc, lo in rows:
    print("  %4d    %14.2f   %15.2f   %8.3f   %15.3f" % (n, mr, mf, acc, lo))
print("")
print("  feature            p90 / p50 of the inter-key gaps")
print("  decision cut       %.2f" % CUT)
print("  in-sample accuracy %.3f at the largest n" % max(r[3] for r in rows))
print("  keystrokes on a typical page   4 to 9, so n=4 is the realistic case")
real_big = [human_iki(rng) for _ in range(600)]
fake_big = [synthetic_iki(rng) for _ in range(600)]
print("  median gap, both arms           %.3f s vs %.3f s"
      % (median(real_big), median(fake_big)))
print("  mad gap, both arms              %.3f s vs %.3f s" % (mad(real_big), mad(fake_big)))
print("  tail ratio, both arms           %.2f vs %.2f" % (tail_ratio(real_big), tail_ratio(fake_big)))
Separating human IKI from a matched-mean Gaussian on one scale-free feature

  n    tail ratio real   tail ratio synth   accuracy   95pct lower bound
     4              2.15              1.15      0.713             0.657
     8              8.77              1.36      0.912             0.855
    16              8.76              1.34      0.953             0.897
    32              8.64              1.33      0.962             0.905
    64              9.99              1.36      0.993             0.937
   128             10.58              1.38      1.000             0.943
   256             10.41              1.37      1.000             0.943

  feature            p90 / p50 of the inter-key gaps
  decision cut       2.60
  in-sample accuracy 1.000 at the largest n
  keystrokes on a typical page   4 to 9, so n=4 is the realistic case
  median gap, both arms           0.131 s vs 0.145 s
  mad gap, both arms              0.046 s vs 0.032 s
  tail ratio, both arms           11.22 vs 1.38

Three numbers in that output determine your engineering priority. The median inter-key gap barely differs between the arms — 0.131 s versus 0.145 s — so any defence thresholded on mean speed will catch a lot of innocent fast typists and a lot of careful bots. The MAD differs by less than 1.4x, so spread alone is weak. The tail ratio differs by 8x, which is the whole signal: human typing is heavy-tailed because people pause to think, and a Gaussian generator has no tail by construction.

The n=4 row is the one that matters operationally. At four keystrokes the same feature that reaches 1.000 accuracy at 128 samples only reaches 0.713, because a single pause among four gaps moves p90/p50 enormously. This is why behavioural defences mostly do not block on keystroke telemetry alone: they accumulate it into a session-level feature over a longer window, and they weight it against signals that are available immediately. For you, the implication is that perfecting keystroke timing buys you very little on a short form, and that the effort is better spent on the dwell, scroll and navigation-shape signals, which apply to every page rather than only the pages with fields.

Noise Floors and Why Matched Variance Fails#

Your generator has to sit inside the human distribution, not merely somewhere near it. There are three distinct failure modes and they are not equally fixable.

Too little variance. The constant-interval case from the timing lesson. Caught immediately, and the most common generator bug because a fixed delay is the easiest thing to write.

Too much variance. Uniform random between 0.5 s and 8 s. This looks like jitter but it is a different tell: the histogram has a hard rectangular support where the human histogram has a smooth, asymmetric decay, and the shape is a uniform density. Human gaps are lognormal-ish; a uniform draw has zero probability at the exact endpoints and constant density in between. If your defence team plots the histogram, this is obvious.

Correct variance, wrong shape. Match the median and the MAD and you are still wrong, as the KS output above demonstrates. You need the tail.

The practical generation recipe that survives: fit a mixture on the log scale, not a single distribution. In the snippet above the human model is a three-component lognormal mixture — 62% fast bursts, 19% slower bigrams, 19% thinking pauses — and the synthetic arm is a single Gaussian in linear space. The mixture is what produces the 11.22 tail ratio. A single lognormal fit will get you most of the way; the mixture gets you the rest.

The second half of the recipe is conditional structure, which no marginal distribution can capture. Gap length should depend on the character pair (vowel-vowel is faster than consonant-consonant), on position (the first character of a word is slower than the interior), and on whether the field is a password (uniform, slow, no pauses) versus a search box (fast, bursty, frequent corrections). Generate the pair-conditional table from real traces and apply it, rather than sampling one distribution per field.

How Models Degrade Against Synthetic Input#

Every behavioural model has a failure mode an adversary can walk into, and knowing which one you are exploiting tells you how much time you have bought.

Averaging models collapse under matched means. A classifier built on mean IKI, mean velocity, mean dwell is defeated by a generator that matches the means. This is the most common model class and the most commonly beaten; it is also the only class you can defeat with a simple generator.

Shape-based models require matching shape, which is the mixture work above. These hold up considerably longer, because the adversary has to fit a multi-modal distribution to a feature whose support they have to discover without labelled examples.

Interaction models require matching conditional structure — gap correlated with grapheme class, dwell correlated with word count, velocity correlated with distance. Very hard to match blind, and this is the current frontier.

Cross-signal consistency models are the real endgame. A model that checks whether the pointer kinematics are consistent with the claimed device class, whether the dwell correlates with the content, and whether the timing is consistent with the render pipeline, is not defeated by any single generator because it requires them all to be simultaneously correct. The cost curve for you is that every additional such model makes your generator more constrained.

There is also a degradation that has nothing to do with model quality: drift. Real human behaviour changes with the device, the input method, the time of day and the user's familiarity with the site. A model trained on desktop mouse traces degrades against touch input, and a model trained on a login page degrades against a long form. So behavioural defences have to be maintained like everything else, and their error rate is not zero.

Finally, note the direction of the failure. When a behavioural model is wrong, it is wrong in the direction of blocking real users, because the threshold that catches the obviously-synthetic also catches the fast, the disabled, the trackpad user and the one on a flaky connection. That asymmetry is why behavioural rules are almost always applied as additive score contributions rather than as standalone blocks, and it is the subject of from the model side.

The High-Leverage Baseline#

If you implement nothing else in this area, implement these four, because they are cheap, they apply to every page, and they are the ones most pipelines leave at zero:

  • Tab-visibility and visibility-change events. Never fired, or fired once per page load regardless of engagement, is a strong and trivially-visible signal.
  • Field-level dwell with content-conditional means — scale the pause by text length and field type, not by page.
  • Scroll events with a realistic profile including direction changes and a terminal pause, not a scroll to the bottom.
  • Navigation history that reflects reading order, including a plausible number of same-site navigations before the target page.

Everything in the top half of the inventory — pointer kinematics, key dwell durations, digraph timing — is worth doing only after these four are in place, and only if you have real traces to fit against. Generating a plausible-looking bezier curve you have never validated against a human corpus produces the "too smooth" failure the earlier section describes, and it costs more engineering than the four baseline items combined.

Building the Corpus: Where Real Traces Come From#

Every recommendation above assumes you have traces, and the quality of what you build is bounded by the quality of what you fit against. The sources, in order of preference.

Instrumented real users. A first-party script that records the four baseline signals, with consent, aggregated rather than per-session. This is the only source that is both abundant and correctly labelled, and it is far cheaper than most teams expect because the events are already being fired — the hard part is the labelling, not the collection. Label by outcome: sessions that converted, sessions that were challenged, sessions that a human confirmed as abusive.

Your own browsing, done properly. Recorded from real hardware, over days, on the sites you actually care about. The trap is that you are not a typical user: you know the pages, you navigate directly, you do not search. The result is a corpus of experts, whose dwell and navigation behaviour is systematically unlike a first-time visitor's.

Third-party fingerprint databases and research datasets. Useful as a prior and terrible as a fit target: they are aggregates, the distribution is often normalised away, and the sample is heavily weighted toward volunteer technical users. Good enough to check that your generator's shape is roughly plausible.

Generated data. Only useful for testing your detector, never for fitting your generator. Fitting to generated data is how you end up with a generator that matches itself.

The properties the corpus needs, in priority order:

  • Conditional structure, not just marginals. Pairs of consecutive keys, with the character classes. The mixer's per-bigram table from the noise-floor section needs this and nothing else.
  • Content labels. Word count, field type and page type alongside every dwell measurement, because dwell is meaningless without knowing what was being read.
  • Device labels. Input method and platform. Mouse traces and touch traces are different datasets and pooling them produces a bimodal distribution that matches nobody.
  • Timestamps at millisecond resolution. Sub-10ms resolution, or a large fraction of the fast-bigram component is quantised away.

And the size: the sample-size section above is the constraint. A few thousand labelled sessions is enough to fit a per-bigram table and a dwell regression. It is nowhere near enough to train a model, which is why the recommendation throughout has been fit a small number of interpretable parameters rather than learn the distribution — you can validate five parameters against 300 traces, and you cannot validate five thousand against the same 300.