PYRMI security

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

  1. 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 fetch and performance.now, a page clock that doesn't keep pace with real time, and probably-open devtools.
  2. 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.
  3. 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.
  4. Watch the dashboard. Every verdict, which checks fired, and which users keep getting flagged.
in your page
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) });
on your server · npm install pyrmi-server
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

What it actually stops

These checks live on the server, so they don't depend on the browser telling the truth:

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

The source is on GitHub, and issues and pull requests are welcome. The full threat model is in the README and the docs.