How long a key is held
From keydown to keyup on
the same key. Weighted 1.0.
A credential vault that verifies how you type, not only what you type. Everything below is what the code does — the repository is public if you would rather read that.
Between 2007 and 2008, building a custom MVC framework called Fishtea at a large
digital media company, I added a small telemetry script to its admin login:
Keystroke Captcha. Visual CAPTCHAs were annoying people and scrapers were getting
good. The idea was to listen to DOM key events — keydown and
keyup, through YUI's
YAHOO.util.Event — and decide whether a form was being
filled in by a person or a machine.
It measured two things: flight, the gap between one key and the next, and dwell, how long a key stays down. Both worked for telling people from scripts. But the timings kept doing something else, which is the part that mattered: they separated one person from another. Everyone has an involuntary neuromuscular cadence, and it is about as distinctive as handwriting.
So I forked it into a biometric salt for the login itself, and demonstrated it the only way that is convincing. I wrote my password on the whiteboard in plain text and invited the room to log in as me. One engineer after another typed the exact characters and was refused. I sat down, typed the same string, and was let in on the first attempt.
Then smartphones arrived. Soft keyboards, touch targets and autocorrect flattened the timings the whole thing depended on, and the prototype went in a drawer for sixteen years.
What brought it back is that the attack changed. Credentials leak by the billion and attackers no longer break in — they log in, with valid details, increasingly through automation that fills a form faster and more evenly than any hand can. The useful question stopped being what secret was entered and became who or what entered it. That is a question a keyboard can still answer.
From keydown to keyup on
the same key. Weighted 1.0.
Release of key k to press of k+1. The most personal of the three, so it is weighted 1.5.
Down of k to down of k+1. Captures pairs your hands have learned as one motion. Weighted 1.0.
Total time, and the mean and spread of each of the three. Weighted 0.5 — context, not evidence.
A credential of n keystrokes therefore produces a vector of exactly
3n + 5 numbers. Timings come from
performance.now(), which browsers deliberately coarsen to
roughly a tenth of a millisecond to blunt timing attacks — more than enough resolution
here, and worth stating plainly rather than claiming microseconds we do not get.
// The browser derives everything first. Argon2id at m=64 MiB, then HKDF.
POST /auth/login
x-ck-device, x-ck-nonce, x-ck-timestamp, x-ck-signature: Ed25519 over the body
{
"username": "engineer@domain.com",
"authHash": "qN7v…", // a derived key, never the passphrase
"featureVector": [74, 91, 62, … ] // 3n+5 timings, scored then dropped
}
// A band and a sentence. No number, ever — see below.
200 { "band": "pass", "keyVersion": 4 }
200 { "band": "grey", "stepUp": ["backup_code"] }
401 { "band": "fail", "error": "rhythm_mismatch" }
It is compared against your profile and discarded in the same request. What persists is per-feature means and standard deviations, floored at 8 ms — and a mean cannot be replayed as typing.
You are told a band and a sentence. A live number is the one signal an attacker can iterate against — type, read 0.31, adjust, read 0.38 — and it gives an honest user nothing they can act on.
Each device holds an Ed25519 keypair and signs a nonce, a timestamp and a hash of the body. A captured request cannot be replayed from anywhere else.
A Backspace or an Escape that leaves no character behind is still a keystroke, and still part of the secret. Two people typing the same visible text can be typing different scripts.
Enrolment takes eight samples and reduces them to a mean μ and a standard deviation σ per feature, with σ floored at 8 ms so a feature you happen to be very consistent on cannot become a hair trigger. A later attempt is scored feature by feature:
zk = |xk − μk| / σk
Σ wk · 1 / (1 + (zk / 2)²)
score = ────────────────────────────────
Σ wk
The choice worth explaining is why each feature is passed through 1/(1 + (z/2)²) rather than summed as a squared distance. A Euclidean distance is unbounded, so one freak value — a sneeze, a doorbell, a key that stuck — dominates every other feature and refuses a legitimate person. This saturates: a wildly wrong feature contributes almost nothing, and the verdict rests on the pattern rather than on its worst moment.
The score falls into one of three bands, and the thresholds are yours to set. Relaxed passes at 0.55, Medium at 0.62, Strict at 0.70. Between the pass and grey thresholds you are asked to type once more; below the grey threshold the attempt is refused and the server share of your vault key is not released.
The Rhythm Trial runs this exact code in your browser → — same tokenizer, same vector, same weights, same bands, and no network requests at all.
| Threat | Password vault | Hardware key | CypherKey |
|---|---|---|---|
| Credential database leak | Vulnerable | Protected | Protected — the cadence is not in the dump |
| Credential stuffing by a bot | Vulnerable | Protected | Protected — synthetic timing does not match a person |
| Reverse-proxy phishing | Vulnerable | Protected | Partial — the vector is bound to the device signature |
| Keylogger that records timing | Compromised | Protected | Partial — a replayed cadence can pass |
| Someone watching you type | Compromised | Protected | Partial — a good mimic on a new device meets a step-up |
| Losing the hardware | n/a | Locked out until replaced | Nothing to lose |
| Enrolment friction | None | Register every device | Eight samples, once |
The two "partial" rows are the honest ones. A keylogger that captures inter-key timing as well as characters has captured the factor, and anyone claiming otherwise is selling something. What rhythm defends against is the attack that actually happens at scale: a credential leaked from someone else's breach, replayed from somewhere else by someone — or something — that has never watched you type. A new device always meets a second confirmation, which is what bounds the rest.