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.
npm install motion-attestationOnly need the movement verdict? mini/ is the same model in two small
files: collect, analyze, done. No protocol, no tokens.
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.
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 expiredClient 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 } -------------------|
| 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 |
| 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.
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.
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.
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.
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 |
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.
| 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 |
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.
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.
| 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.
npm run example
# http://localhost:3002npm 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 e2etest/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.
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.
npx prtfm