For the past few months I've been building Synapse Shield by myself. It's a self-hosted bot protection library for Python web apps (FastAPI, Flask, Django, Sanic, Litestar, and plain ASGI) backed by a Rust core and lightweight browser SDKs. Instead of making users click fire hydrants, it scores mouse kinematics, typing rhythm, and touch or scroll behavior on your own server so visitor telemetry never goes to a third-party vendor.
A few days ago in v0.10.4, I shipped the feature I cared about most: client- and server-side Zero-PII keystroke masking. The promise was simple: the server only sees timing intervals and hold durations, never the characters someone types into a form.
Right before tagging v0.10.5, I hit the usual solo-maintainer wall. Nobody is around to review my pull requests, and testing your own code mostly confirms your own assumptions. So I opened fresh, zero-context sessions across different AI models, handed them the repo in Red Team mode, and asked them to break it.
They came back with 10 findings. LLM security reviews hallucinate plenty of nonsense, so I wrote quick local scripts to test each claim before touching any code, and all 10 reproduced.
How I broke my own privacy guarantee
To measure typing rhythm without logging text, the SDKs and the server needed to mask printable characters ("a", "7", "@") while still recognizing non-printable control keys like Enter, Backspace, and ArrowLeft.
I didn't want to type out every valid control key by hand, so I wrote a regex:
# From privacy.py before the fix
_NAMED_KEY_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]+$")
My brain read that as: single characters fail because of +, while named keys like Enter or Backspace pass. What I completely missed is that any multi-character word is also two or more alphanumeric characters. Whenever browser autofill, mobile swipe typing, or an IME committed a whole word like Mustafa in a single event, it matched the regex and went straight to the server in plain text.
That stung, mostly because Zero-PII masking had been the headline of my v0.10.4 release notes three days earlier. The fix was boring and obvious in hindsight: delete the regex in privacy.py and all three JS/TS SDKs (Vanilla, React, Vue), and replace it with a closed allowlist of W3C UI Events control key names. Anything outside that set becomes "masked".
Blocking real phone users on their fourth tap
While multi-character words slipped past the privacy filter, real people on mobile browsers had the opposite problem. If an iPhone or Android user tapped a button four times within five minutes, the fourth tap got rejected with HTTP 403 and a 100% bot risk score.
A stationary finger tap only records one touch coordinate (mouse_points = 1), so there is no movement curve to measure. When mouse_points < 3, my feature extractor fell back to placeholder numbers: avg_jerk = 0.0, straightness = 1.0, and total_distance = 0.0. My request pipeline saved those placeholders into the user's session history anyway. On the fourth tap, the cross-request invariance check saw three (0.0, 1.0, 0.0) entries in a row with zero variance between them and decided a bot was replaying a recorded trace.
I had written dozens of tests for synthetic Bézier bots and almost none that simulated a normal person tapping around on a phone screen. Session history now ignores requests unless there is a real trajectory (mouse_points >= 3 and total_distance > 0).
Two embarrassingly cheap bypasses
The kinematic engine checks for empty payloads, straight-line movement, and synthetic tremor, yet a headless script could still score 0.0% risk by sending empty mouse, click, and keystroke arrays alongside one scroll event:
{
"mouse_movements": [],
"clicks": [],
"keystrokes": [],
"scrolls": [{"y": 120, "t": 500}]
}
Because scroll_count counted toward total_interactive_events, that lone scroll passed the zero-interaction check, and none of the mouse or keyboard rules fired because their event counts were zero. A 25px ruler-straight mouse move with zero timing variance slipped through for a similar reason, since the desktop linearity checks only started above 30px.
On top of that, all six framework middlewares opened with the same check:
if method not in ("POST", "PUT", "PATCH"):
return await self.app(scope, receive, send)
I had written that filter in six separate adapter files and forgotten "DELETE" every single time. Calling DELETE /api/account/123 on a protected route skipped token verification and IP bans altogether. All six adapters now read from a shared PROTECTED_METHODS = ("POST", "PUT", "PATCH", "DELETE") tuple in core.py.
The remaining findings were smaller plumbing bugs that still would have caused headaches in production:
- The Vue SDK's
solvePow()helper generated its own random salt instead of hashing the server's signedpow_salt, so Vue proof-of-work solutions could never pass verification. React and Vue also kept reusing a consumed single-use challenge in SPAs instead of fetching a fresh one after submission. - The Litestar route guard was missing the 256 KB body cap enforced by the other five adapters and returned
403instead of429when asking a client for proof-of-work. - Event timestamp freshness checks in
tokens.pyonly inspected"t", even though keystroke extractors also read"down","up", and"time"(and didn't exclude Pythonbool, which is a subclass ofint). - Enabling
extension-moduledirectly onpyo3inCargo.tomlworked on Windows locally, where MSVC linkspython3xx.libanyway, and brokecargo teston Linux in CI becauseextension-modulesuppresses-lpython. Making it an optional Cargo feature enabled by Maturin fixed Linux builds. - The standalone FastAPI gateway (
main.py) had drifted behindcore.py:/metricswas open without authentication, CGNAT sessions weren't salted with a User-Agent hash, and raw IPs were written to local logs even whenSYNAPSE_PRIVACY_MODEwas set tohashorstrict.
Testing the test suite with mutation testing
Each of those 10 bugs now has regression tests pinning the fix, bringing the repo to 536 Python tests, 78 Vitest SDK tests, and 42 Rust unit tests (~88% line and branch coverage in Python).
Since line coverage doesn't tell you whether assertions actually catch broken logic, I also tried mutation testing (mutmut) on tokens.py, core.py, and storage.py. It edits your source code one operator at a time (turning < into <= or deleting an early return) and checks whether pytest fails.
My first full run reported a 92.1% mutation score. That felt great for about an hour, until I realized a bug in my runner had left mutated files sitting on disk across runs and inflated the kill count. After throwing that result out and restoring clean source before every mutant, the real number settled around 88% across 1,145 evaluated mutants. That run happened right before this last batch of patches, so the newest code still needs its weekly CI mutation run.
Two breaking changes in v0.10.5
If you're upgrading from an earlier 0.10.x release, check two things first:
-
DELETErequests to protected paths now require a token and return HTTP 403 without one. SinceDELETErequests rarely carry a JSON body, pass the token in theX-Synapse-Tokenheader. -
GET /metricson the standalone gateway now requires your admin secret (X-Admin-SecretorAuthorization: Bearer). Add that header to your scraper if you poll it.
What I still don't know
All of my human test traces pass, mobile single-taps included, though synthetic fixtures are no substitute for messy live traffic and assistive tools. I still don't have a real false-positive rate from production visitors. Next on my list is running a shadow-mode pilot (mode="shadow"), where Synapse Shield scores and logs traffic without blocking anyone.
No human security researcher has audited the codebase yet either. If a cross-model Red Team pass turned up 10 real bugs in code I thought was ready to release, a dedicated human auditor will probably find more.
Trying it out
pip install synapse-shield
If you test it on your own app, start in shadow mode (mode="shadow") and inspect what it flags on your own visitors before switching to mode="enforce". And if you manage to break it, please send a report through GitHub Security Advisories instead of opening a public issue.
0xStoic-bit
/
Synapse_Shield
Next-gen open-source behavioral biometrics & bot mitigation engine. Sub-millisecond (<0.5ms) Turnstile alternative powered by human kinematics (Jerk da/dt), Poisson anomaly detection, and zero-PII telemetry.
🛡️ SYNAPSE SHIELD
Next-Gen Open-Source Behavioral Biometrics & Bot Mitigation Engine
A privacy-first, zero-friction, self-hosted alternative to Cloudflare Turnstile.
Key Features • Architecture • Quickstart • Developer Guide • Benchmarks • Math • Featured On
⚡ Overview
Synapse Shield replaces intrusive legacy CAPTCHAs and expensive proprietary cloud WAFs with sub-millisecond behavioral biomechanics.
By evaluating natural human neuromuscular micro-tremors (Jerk:
✨ Key Features & Security Architecture (v0.10.5)
| Feature | Description |
|---|---|
| 🛡️ Zero-PII Keystroke Shield & SPA Lifecycle | Universal client-side masking of every printed character — only keys on a closed allow-list of UI Events control keys (Enter, ArrowLeft, Unidentified, F1–F24, …) are sent by name, so emoji, ligature |
Top comments (2)
The regex bug is a perfect example of a pattern that reads correctly in your head and fails in the opposite direction in practice — you were thinking single characters versus named keys like Enter, and the pattern quietly also matched any ordinary word, so a name typed into a form went to the server in plain text. The mobile tap story stuck with me just as much: a stationary tap producing placeholder values of 0.0, 1.0 and 0.0, then being flagged as a replayed bot trace on the fourth tap, shows how a feature built for desktop mouse movement can punish the most ordinary phone behaviour. Replacing the regex with a closed allowlist feels like the right fix — it turns an open-ended guess into a list you can actually audit.
The placeholder float trap on stationary taps is a classic invariant defect. When a detector populates missing movement vectors with fixed zeros, the default value itself becomes a synthetic fingerprint that triggers cross-request replay checks. Dropping single-point taps from the invariance buffer entirely is usually safer than feeding dummy coordinates into variance calculations.
The other edge case that often bites closed W3C key allowlists on mobile is IME composition. When virtual keyboards commit whole words or predictive text through compositionend events, KeyboardEvent.key frequently yields Unidentified or Process before dumping the text into the input field. If the collector only tracks keydown and keyup intervals, mobile timing profiles can look unnaturally sparse compared to desktop keystrokes.