What a browser can't prove: building an open-source bot and tamper check
Pyrmi Security is open-source (Apache 2.0) bot and tamper detection for your site: one script tag in the page and three lines on your server. It catches bots, scrapers, replayed requests and tampered pages before your server trusts a sign-up, a login, a vote, a checkout, a coupon code or a score.
The thing I kept running into is that a lot of bot and tamper protection for the web promises something it can't deliver. Whoever owns the browser can switch off any check that runs in it. You can't block devtools, and you can't stop code from running. So Pyrmi is built around a different idea: the browser collects evidence, and your server makes the call.
Full honesty: I built this with Claude. I came up with the idea and made the calls, and a friend and I spent a couple of weeks trying to break it, which found a bunch of holes that are now fixed.
How it works
- Add one script tag. On a real page load it gets a signed, single-use session from the server, then watches for signs of automation and tampering: webdriver and headless markers, scripted input events, typing and clicking that's too even to be human, replaced built-ins like
fetchandperformance.now, a page clock that doesn't keep pace with real time, and probably-open devtools. - Seal the request. Right before the page sends a protected action,
Pyrmi.seal()signs the exact fields with a per-session key, so a body that's changed on the way gets rejected. - Ask for a verdict. Your server calls
pyrmi.verify()and gets back accept, review or reject, a risk score from 0 to 100, and the reasons. - Watch the dashboard. Every verdict, which checks fired, and which users keep getting flagged.
const s = await Pyrmi.start(); // on page load
await Pyrmi.flush(); // right before the protected request
const body = await Pyrmi.seal({ email }); // signed: changed on the way = rejected
await fetch('/signup', { method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });const { Pyrmi } = require('pyrmi-server');
const pyrmi = new Pyrmi({ secretKey: process.env.PYRMI_SECRET_KEY });
const v = await pyrmi.verify({ sealed: req.body, req });
if (v.verdict === 'reject') return res.status(403).end();What it catches
- Bots and headless browsers: webdriver flags and Playwright, Puppeteer, Selenium and Cypress markers.
- Scrapers and direct API calls: a request with no signed session from a real page gets flagged.
- Replayed requests and forged or edited tokens.
- Tampered pages: replaced
fetch,performance.nowand other built-ins, plus probably-open devtools. - Scripted input: synthetic events, and typing or clicking that's too even or too fast for a person.
- Impossible values: actions sent faster than a person could, or numbers past your caps.
What it actually stops
These checks live on the server, so they don't depend on the browser telling the truth:
- Replayed requests. Each token works once and expires.
- Forged or edited tokens. Sessions are HMAC-signed, so changing one byte is a reject.
- Actions sent faster than a person could. The minimum time is measured from when the server issued the token, not from a number the page claims.
- Impossible values. You set a max score, or a max score per second.
- Direct API calls and scrapers that never loaded a real page.
- Floods. Every endpoint is rate limited per network and per project.
What it only makes harder
Anything the browser reports. A careless bot, a lazy edit of the script, or a Puppeteer script with default settings gets caught. A determined person running a real browser they control can send exactly what the real script sends. So treat the risk score as a reason to flag or review, not as proof. I'd rather say that up front than have someone find out the hard way.
For games and anything with a score
If your game is deterministic (same seed plus same inputs gives the same result), Pyrmi hands the page a seed, and your server can re-run the recorded inputs with a simulate function. Inputs that don't reproduce the claimed score are rejected. That's the one anti-cheat check that doesn't rely on the client at all, and it's the one I'd lean on for leaderboards. The replay docs show how.
Try to break it
The homepage sandbox stands in for a form on your site. You can send an action normally, replay it, edit its token, send an impossible value, change it on the wire, or skip the page and call the API directly, and see the verdict your server would get. You can also open devtools and paste performance.now = () => 0 into the console. Everything you do shows up on the public sandbox dashboard.
Setting it up
It's free to use on pyrmi.com while it's in early access, or you can self-host it on Vercel with a free Upstash Redis. There are no dependencies, just Node 18+. If you use Claude Code, Cursor or another coding agent, there's an install guide written for agents: paste one prompt and it wires up the script tag and the server check. For high-value actions like payouts there's an optional switch that also requires a Cloudflare Turnstile pass.
What I'd love feedback on
- Does the threat model make sense, and is anything in it wrong?
- False positives on real sites. Error trackers like Sentry and frameworks like zone.js wrap built-ins, so Pyrmi scores those much lower than a real tamper, but I'd like to hear about anything else that trips it.
- What would make you actually drop it into a project?
The source is on GitHub, and issues and pull requests are welcome. The full threat model is in the README and the docs.