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
- Create a project on the dashboard. You get a public key (
pk_...) and a secret key (sk_...), shown once. - Put the secret key in your server's environment as
PYRMI_SECRET_KEY. Never put it in a web page. - Add the script tag to the pages you want to protect, send the session token with each protected request, and add one
verifycall on your server.
Browser script
<script src="https://pyrmi.com/sdk/pyrmi.js" data-site="pk_..."></script>
| call | when |
|---|---|
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
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:
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.
| reason | effect | meaning |
|---|---|---|
invalid_token | reject | Token missing, forged or edited. |
token_reused | reject | The same session was already used once: a replayed request. |
expired / wrong_project | reject | Old token, or one from another project. |
too_short | reject | Sent sooner after page load than your minimum. |
impossible_time | reject | Claims more time than its session has existed. |
score_over_max / score_too_fast | reject | The value sent breaks a limit you set. |
replay_mismatch | reject | Replaying the recorded inputs gave a different result. |
rate_limited | reject | Too many checks for one user in a minute. |
automation | +70 | Webdriver, headless or a bot framework. Real visitors never have these, so this alone rejects at the default settings. |
no_client | +40 | No telemetry ever came from a real page: a direct API call or a scraper. |
synthetic_input | +40 | Input events created by script. |
speed_hack / time_warp | +35 | Page or app clock out of step with real time. |
tampered_runtime | +30 | Built-in functions replaced. Some analytics and error tools wrap fetch, so this one alone only adds risk. |
inhuman_input | +25 | Input too fast or too regular for a hand. |
ua_changed / idle_input | +20 / +15 | Browser changed mid-session / long session with almost no input. |
devtools_open / ip_changed | +10 | Weak 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:
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.