PYRMI security

Docs

Pyrmi Security checks requests from your site in two halves. A browser script starts a signed session on a real page load and reports signs of bots and tampering. Your server then asks Pyrmi for a verdict before it trusts the request: a sign-up, a login, a vote, a checkout, a score, or any endpoint that bots and scrapers hit. You need both halves: the browser half alone protects nothing, because whoever runs the browser controls it.

Quick start

  1. Create a project on the dashboard. You get a public key (pk_...) and a secret key (sk_...), shown once.
  2. Put the secret key in your server's environment as PYRMI_SECRET_KEY. Never put it in a web page.
  3. Add the script tag to the pages you want to protect, send the session token with each protected request, and add one verify call on your server.

Browser script

html
<script src="https://pyrmi.com/sdk/pyrmi.js" data-site="pk_..."></script>
callwhen
await Pyrmi.start()On page load, or when a flow you protect begins. Returns { token, seed, session, expiresAt }. Each token is good for one verify, so call it again after each protected request.
await Pyrmi.flush()Right before you send the protected request. Sends the latest telemetry.
Pyrmi.mark(seconds)Optional, for apps with their own clock (a game, a timed quiz). Lets the server catch speed and slow-motion hacks.
Pyrmi.report()What the script has noticed so far, if you want to show it.

Send token to your server with the request. The script watches for webdriver and headless browsers, bot frameworks, scripted input events, inhumanly fast or even typing and clicking, replaced built-ins like fetch and performance.now, a page clock that doesn't match real time, and probably-open devtools. It sends this as small beacons every 10 seconds while the tab is visible.

The script you load is a minified build and it reports a fingerprint of its own code, so an edited copy shows up as tampered_runtime. This telemetry is advisory: it can only raise risk, and it can never prove a request is human, because anything the script sends an attacker running a real browser can send too. The real protection is the server-side verdict — the signed, single-use session token, the duration and score limits, and the rate limits — none of which depend on the browser cooperating. Set maxScore, maxScorePerSec and minDurationMs for anything that matters, and treat risk as a reason to flag or review, not as proof. The readable source is src/sdk/pyrmi.js in the repo.

Server check

node · npm install pyrmi-server
const { Pyrmi } = require('pyrmi-server');
const pyrmi = new Pyrmi({ secretKey: process.env.PYRMI_SECRET_KEY });

const v = await pyrmi.verify({ token, user: email });
if (v.verdict === 'reject') return res.status(403).json({ error: 'blocked' });
createAccount(email, { flagged: v.verdict === 'review' });

Not on Node? It's one HTTPS call from your server:

any language
curl https://pyrmi.com/api/v1/verify \
  -H "Authorization: Bearer $PYRMI_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"token":"...","user":"alice@example.com"}'

If Pyrmi can't be reached, pyrmi-server rejects by default. Pass failOpen: true to let requests through unverified instead.

Verdicts and reasons

Each verify returns accept, review (let through, flagged on the dashboard) or reject, plus a risk from 0 to 100. Hard reasons reject on their own. Soft signals add up.

reasoneffectmeaning
invalid_tokenrejectToken missing, forged or edited.
token_reusedrejectThe same session was already used once: a replayed request.
expired / wrong_projectrejectOld token, or one from another project.
too_shortrejectSent sooner after page load than your minimum.
impossible_timerejectClaims more time than its session has existed.
score_over_max / score_too_fastrejectThe value sent breaks a limit you set.
replay_mismatchrejectReplaying the recorded inputs gave a different result.
rate_limitedrejectToo many checks for one user in a minute.
automation+70Webdriver, headless or a bot framework. Real visitors never have these, so this alone rejects at the default settings.
no_client+40No telemetry ever came from a real page: a direct API call or a scraper.
synthetic_input+40Input events created by script.
speed_hack / time_warp+35Page or app clock out of step with real time.
tampered_runtime+30Built-in functions replaced. Some analytics and error tools wrap fetch, so this one alone only adds risk.
inhuman_input+25Input too fast or too regular for a hand.
ua_changed / idle_input+20 / +15Browser changed mid-session / long session with almost no input.
devtools_open / ip_changed+10Weak hints, worth a look only alongside others.

Settings

On the dashboard: the risk levels for flagging (default 40) and rejecting (default 70), the shortest time from page load to a request, the pages allowed to start sessions, and the value limits below. You can also make a new secret key there if yours leaks.

Values and clocks

If a request carries a number you'd hate to see faked, such as points, a quantity or a game score, send it as score and set a max value and a max value per second on the dashboard. If your page has its own clock, call Pyrmi.mark(seconds) as it runs and Pyrmi checks it against real time.

Replay check (deterministic apps)

If your app is deterministic, meaning the same seed and the same inputs always give the same result, record the inputs and send them along. Seed your RNG with the session's seed so nobody can rehearse in advance. On the server, pass a simulate function that re-runs it:

node
const v = await pyrmi.verify({
  token, score, durationMs, user, replay: inputs,
  simulate: (inputs) => runApp(seed, inputs),   // returns { score, durationMs }
});

A memory edit or a typed-in result won't reproduce, so the verdict is reject with replay_mismatch.

Set it up with an AI agent

Hand your coding agent install.md. It's written for agents like Claude Code and Cursor, step by step, and tells them to keep your secret key out of the code.

Accounts

Sign in on the dashboard with a code sent to your email, or with GitHub or Google, to keep all your sites in one place. One email is one account, whichever way you sign in. Each site gets its own keys and a setup guide, and the guide turns green once your page has started a session and your server has checked one. Nobody else can see or change your sites. A project made with keys only can be moved into your account with its secret key. Self-hosters switch each way in on with its own env vars (SMTP_HOST, SMTP_USER and SMTP_PASS for any mailbox, or RESEND_API_KEY; GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET, GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET); the README has the steps.

Self-hosting

Everything is MIT, so you can run your own copy: deploy the repo to Vercel, add a free Upstash Redis from Vercel's Storage tab (it sets KV_REST_API_URL and KV_REST_API_TOKEN), and set PYRMI_SECRET to a long random string. Then point the script at it with data-api="https://your-deploy/api/v1" and pyrmi-server with endpoint. For local work: PYRMI_KV=memory node scripts/dev.js.

How pyrmi.com protects itself

The dashboard's "new project" form sends a Pyrmi token, and the API only creates the project on a clean accept. You can watch what it catches on pyrmi.com's own dashboard. Every endpoint is also rate limited per network, and wrong secret keys are capped per network.

Limits, honestly

Pyrmi can't block devtools or stop code running in a visitor's browser, and nothing can: the visitor owns that browser. A determined attacker can hide most browser signals. What they can't fake is a signed, single-use session from a real page load, timing that fits the real time it took, and, for deterministic apps, inputs that reproduce the result. Treat browser signals as evidence, and let your server's verdict be the gate.