Skip to content

Repository files navigation

motion-attestation

Tells people apart from bots by how they move, not by what they can solve. No puzzles, no images of traffic lights, nothing for your visitors to do. The library watches the ordinary mouse, touch, keyboard and scroll activity that happens anyway, and returns a score with the reasons behind it.

A hand is a physical thing. It brakes before it arrives somewhere, it trembles a little, it corrects itself, and it never travels in a perfectly straight line. Scripts have to imitate all of that at once, and that turns out to be hard.

Zero dependencies, Node 18+, ESM.

Install

npm install motion-attestation

Only need the movement verdict? mini/ is the same model in two small files: collect, analyze, done. No protocol, no tokens.

Quick start

Collect in the browser:

import { createCollector } from 'motion-attestation';

const collector = createCollector();
collector.attach();

collector.bind(document.getElementById('submit'), 'submit-btn');
collector.bind(document.getElementById('agree'), 'agree-checkbox');

const interval = setInterval(() => {
    if (!collector.isReady()) return;

    clearInterval(interval);
    const data = collector.getData();
    collector.detach();
    sendToServer(data);
}, 500);

Analyze on the server:

import { analyze, classifyScore } from 'motion-attestation';

const { score, penalty, reasons, categories } = analyze(data);
// score: 0.0 to 1.0, where 1.0 reads as human
// penalty: everything that was deducted
// reasons: ["[mouse] Low curvature entropy: 0.82 (straight-line)"]
// categories: per category { penalty, maxPenalty, reasons }

const verdict = classifyScore(score);
// "human" | "suspicious" | "bot"

reasons is meant to be read. When something is rejected you can see exactly which signal decided it, which makes tuning a threshold much less of a guessing game.

Challenge and response

If you want a signed token out of it, the package ships a small server:

import { createServer } from 'motion-attestation';

const attestation = createServer({
    secretKey: process.env.MOTION_SECRET,
    scoreThreshold: 0.5,
});

import { createServer as createHttpServer } from 'node:http';
const httpServer = createHttpServer(attestation.handler());
httpServer.listen(3000);

const payload = attestation.validateToken(token);
import { createCollector } from 'motion-attestation';

const { challengeId } = await fetch('/interactions/init', {
    method: 'POST',
}).then((response) => response.json());

const collector = createCollector();
collector.attach();

const data = collector.getData();
collector.detach();

const response = await fetch('/interactions/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ cid: challengeId, d: data, ts: Date.now() }),
});
const { cleared, score, token, flags } = await response.json();

Signing tokens yourself works too:

import { signToken, verifyToken, generateKey } from 'motion-attestation';

const key = generateKey();
const token = signToken({ score: 0.95, iat: Date.now() }, key);
const payload = verifyToken(token, key); // null if invalid or expired
Client                                    Server
  |                                          |
  |--- POST /interactions/init ------------->|  Create challenge
  |<-- { challengeId, ttl } -----------------|
  |                                          |
  |    +----------------------+              |
  |    | Collect for 3-15s:   |              |
  |    | * Mouse movement     |              |
  |    | * Click positions    |              |
  |    | * Keystroke timing   |              |
  |    | * Scroll patterns    |              |
  |    | * Touch + pressure   |              |
  |    | * Gyro/Accel sensors |              |
  |    | * Event ordering     |              |
  |    +----------------------+              |
  |                                          |
  |--- POST /interactions/verify ----------->|  Analyze biometrics
  |    { cid, d, ts }                        |
  |<-- { cleared, score, token, flags } -----|
  |                                          |
  |--- GET /protected ---------------------->|  Bearer token validation
  |    Authorization: Bearer <token>         |
  |<-- { message, score } -------------------|

Configuration

Option Default What it does
secretKey Random 32 bytes Token signing secret
scoreThreshold 0.5 Minimum score to clear (0.0-1.0)
debug false Include full analysis in response
challengeTtl 60000 Challenge expiry in milliseconds

What gets collected

Category Data Desktop Mobile
Mouse position Sub-pixel x,y with timestamps *
Click landing Offset from target center plus dwell time *
Keystroke timing Hold duration and gaps between keys * *
Scroll behavior Position, delta, timestamps * *
Touch events Position, pressure, contact radius *
Accelerometer 3-axis acceleration readings *
Gyroscope 3-axis rotation rate *
Device orientation Alpha, beta, gamma angles *
Event ordering Timestamped sequence of all event types * *
Bound element hits Click offset from center per bound element * *
Engagement Time-to-first-interaction, session duration * *

Which keys were pressed is never recorded, only how long each press lasted.

Element binding

Want to know how accurately a specific button was clicked? Bind it:

const collector = createCollector();
collector.attach();

collector.bind(submitButton, 'submit');
collector.bind(checkbox, 'agree');

// getData() then includes:
// bc: [[offsetX, offsetY, dwell, width, height, time, index], ...]
// bl: ['submit', 'agree']

collector.unbind(submitButton);

Bots tend to click the exact center of an element. People do not.

How the score is built

Every category starts at zero and can only take points away, so the final score is 1.0 - sum(penalties). Three categories do the heavy lifting.

Interaction evidence, up to 0.60

Absent signals are not clean signals. Every other category can only judge the data it receives, so a submission with nothing in it would otherwise sail through. This category asks for evidence before a passing score is possible at all.

Check Human Bot Penalty
Interaction floor 12 or more pointer/click/key/scroll/touch Fewer, scaled linearly to 0.60 at zero 0-0.60

A submission with no interaction at all scores 1.0 - 0.60 - 0.20 (no mouse data) = 0.20, which is a bot.

Collector contract, up to 0.60

These are consistency checks against the collector's own output rather than statistics. A transcript the collector could not have produced was assembled by hand, so one violation is enough.

Check Human Bot
Pointer clock Never runs backward Reordered or spliced samples
Touch clock Never runs backward Reordered or spliced samples
Key dwell Release after press Negative dwell (forged payload)
Tap coupling Click follows a touchend Click precedes every touchend

Path kinematics, up to 0.70 for mouse and 0.30 for touch

Six features per path, each read against a band measured from real captures, combined into a single fit where 1.0 is a hand and 0.0 is a drawn curve.

Feature Human (mouse) Bot Weight
End ratio 0.25 of peak speed or less Full speed into the target 0.22
Path micro-structure 0.3-0.95 second-difference energy per step Near zero, or injected noise 0.22
Turn distribution 0.08-0.7 rad mean turn Ruler-straight, or erratic random walk 0.16
Turns while fast 0.3 rad or less Hard turns at peak speed 0.12
Straightness 0.985 efficiency or less 1.000, point to point 0.10
Speed variation CV 0.35-2.6 Constant-rate stepping 0.08

Braking, micro-structure and turn distribution are the three a generated curve cannot get right all at once, so failing any of them caps the whole path fit instead of being averaged away by the features it does satisfy.

Paths are resampled to a 25 ms cadence first, so a collector that throttles and a browser that quantizes timestamps measure the same as one that does neither. Touch points split into gestures on 100 ms gaps, so a tap is never mistaken for a swipe. The strongest channel carries the session (a tablet user has no mouse path), and a second channel that contradicts it adds 0.08.

The other channels

Category Cap What it looks at
Mouse movement 0.30 Curvature entropy, tremor, jerk, teleports, sub-pixel precision, periodicity
Click landing 0.15 Center offsets, dwell times, zero-duration clicks
Pre-click deceleration 0.10 Whether speed drops in the last 500 ms before a click
Keystroke dynamics 0.15 Hold times, flight times, rhythm entropy, robotic uniformity
Scroll behavior 0.10 Velocity bursts, reversals, reading pauses, fixed increments
Touch biometrics 0.10 Pressure and contact area variation, swipe wobble, end braking
Sensor data 0.10 Accelerometer noise floor, gyroscope tremor, orientation drift
Event ordering 0.05 mousedown before mouseup before click, touchstart before touchmove
Synthetic events 0.15 Clicks and keys both dispatched impossibly fast, zero-time pairs
Engagement 0.05 Impossibly fast first interaction, bursts of events

Putting it together

Final score = 1.0 - sum(category penalties)

  Evidence:    0.60    Contract:     0.60
  Kinematics:  0.70    Mouse:        0.30
  Click:       0.15    Pre-click:    0.10
  Keystrokes:  0.15    Scroll:       0.10
  Touch:       0.10    Sensors:      0.10
  Event order: 0.05    Synthetic:    0.15
  Engagement:  0.05
                       -----------------
  Maximum total:       3.05 (capped at 1.00)

Score >= 0.5 -> cleared, token issued
Score <  0.5 -> blocked, no token

Contract, kinematics, mouse, keystrokes and synthetic events are conclusive on their own. Any of them reaching 80% of its cap holds the score at 0.45 even if every other channel looks fine, so believable keystrokes cannot cover for a drawn mouse path. The gentler channels (scroll, touch, clicks, sensors) only ever add their own penalty, because a mouse wheel scrolling in identical notches is a person, not a verdict.

Geometry decides, not the clock

Interval-regularity checks used to live here and have been removed. A collector that throttles pointer sampling and a privacy browser that quantizes timestamps both make intervals constant by construction, so regular sampling was condemning real people (LibreWolf delivering on a 33 ms grid) while a bot re-timed onto a legal grid walked through. Movement geometry decides now. Timestamps are used only for contract violations and physically impossible speeds.

Why this holds up

Attack What gives it away
Submitting with no interaction The interaction floor: missing evidence costs points, it is not free
Selenium or Puppeteer moveTo Straight lines, uniform velocity, no curvature entropy
Bezier curve mouse libraries Constant second derivative, acceleration far too smooth
Recorded human replay The HMAC nonce blocks replay and the timestamps will not line up
Synthetic dispatchEvent Missing mousedown/mouseup sequence, zero click dwell
Headless touch simulation Zero pressure, zero contact radius, no wobble
Emulated sensors Zero noise floor, no correlation between axes
Fast sendKeys Flight times under 15 ms, no dwell variance
WindMouse or ghost cursor Autocorrelation, missing velocity minima, no pauses
Perlin or Catmull-Rom paths Sub-pixel precision anomalies, periodic autocorrelation
CDP mouse dispatch Zero-time mousedown/mouseup pairs, cross-signal fast dispatch

Human motor control is governed by constraints nobody chooses: Fitts' Law, physiological tremor, the trade-off between speed and accuracy. Individual data points are easy to fake. The distribution they form is not.

Try it

npm run example
# http://localhost:3002

Tests

npm test                # unit, real capture and generator suites
npm run test:e2e        # playwright e2e in chromium
npm run test:browsers   # playwright e2e in chromium and firefox
npm run test:all        # unit plus chromium e2e

Real human captures

test/fixtures/humans/ holds 27 recorded browser sessions across Chromium, LibreWolf, desktop and touch. test/humans.test.js checks that none of them trips a contract violation, that the movement penalty stays at or below 0.35, and that no channel reads as conclusively synthetic.

These fixtures are real captures only. Please never add a generated "human", because a generator that scores as human is a bug to fix, not ground truth to keep.

Bot algorithms

test/e2e/algorithms.js holds 14 humanization algorithms, used two ways: driven through real browsers in the e2e suite, and replayed offline in test/generators.test.js alongside believable clicks, keystrokes and scrolling so the movement layer has to stand on its own.

Algorithm Technique Offline verdict
Linear Straight line with random jitter blocked
Bezier Cubic bezier curve interpolation blocked
Sinusoidal Sine wave path with variable amplitude blocked
WindMouse Ghost Mouse algorithm, wind and gravity model needs the click and key channels
Overshoot Target overshoot with correction needs the click and key channels
Perlin Perlin-like noise displacement blocked
Spring-Damper Physics spring simulation blocked
Gaussian Jitter Gaussian noise on a linear path blocked
Catmull-Rom Spline through random control points blocked
Bell Velocity Bell-curve speed profile needs the click and key channels
Min-Jerk Minimum-jerk profile, braking into the target blocked
Frame-Quantized Bezier re-timed onto a legal 33 ms grid blocked
Synthetic Human Min-jerk plus tremor, a pause and a correction needs the click and key channels
Teleport Three-point jumps blocked

All 14 score below 0.5 through real browsers in both Chromium and Firefox, and so does an injection run that never moves the real mouse and dispatches MouseEvent and KeyboardEvent sequences straight into the page.

Four of them are kinematically plausible, meaning their geometry alone does not condemn them. The click, keystroke and event-order channels are what catch them. Those four are marked todo in the offline suite rather than quietly dropped, because geometry is not sufficient by itself, and a further channel (environment checks or proof of work) is what closes the gap against a carefully tuned mimic.

Formatting

npx prtfm

License

MIT

About

Human interaction verification through behavioral biometrics. Analyzes mouse movement, click patterns, keystroke dynamics, scroll behavior, touch pressure, and device sensors to distinguish humans from bots.

Topics

Resources

Stars

8 stars

Watchers

1 watching

Forks

Contributors

Languages