A privacy-first Proof-of-Work CAPTCHA.
- No image puzzles.
- No user interaction.
- No cookies, no third-party scripts, no tracking.
- Runs on Vercel serverless functions + NeonDB (both free-tier friendly).
Visitors silently solve a SHA-256 hash puzzle in a Web Worker. Suspicious traffic is scored and challenged harder.
- How it works
- Features
- Anti-automation layers
- Quick start
- Deploy to Vercel
- Admin panel
- Integration
- Configuration reference
- Architecture
- Performance
- About the score
- Security notes
- Limitations
- License
-
The widget asks
POST /api/challengefor a random 48-character hex challenge plus a difficulty (default 5 = 5 leading zero hex chars). -
A Web Worker builds itself from an inline Blob URL and solves the puzzle: it brute-forces a nonce until
SHA256(challenge + nonce)starts with N zeros. -
The widget collects behavioral, browser, and environment signals and posts everything to
/api/verify. The server re-checks the hash, marks the challenge solved atomically (replay protection), runs five independent scoring layers, and signs a 5-minute HS256 JWT. -
The widget injects
<input type="hidden" name="irocap-token">into the nearest form and firescallback(token, score). -
Your backend calls
POST /api/siteverifywith your secret and the token. Single-use is enforced by an atomicUPDATE ... WHERE used=FALSE RETURNING.
| Solving | Verified |
|---|---|
![]() |
![]() |
The widget runs silently. The visitor fills the form while the solver works in the background — no clicks, no puzzles, no interruptions.
- Three-tier solver — Web Worker + WASM, Web Worker + inline JS, then
crypto.subtleon the main thread. Same hash, three paths. - HMAC-signed challenges — prevents forged or tampered challenges without a DB lookup.
- Server-observed trusted signals — UA, Accept-Language,
sec-ch-ua,sec-fetch-*, Origin, Referer, timing, ASN. Read from the HTTP request itself, not from the client's body. - Consistency checks — cross-checks headers against client-claimed values.
- Behavioral telemetry — mouse paths, keystroke rhythm, scroll events.
- Environment fingerprinting — probes the JS environment for headless and automation markers (Chrome API surface, plugin count, WebGL renderer string, window geometry, and more).
- Fingerprint reputation — hash-based identity with per-fingerprint trust.
- IP reputation — per-IP trust scoring across many solves.
- ASN blocking — cloud datacenter IPs capped at score 0.15.
- Single-use tokens — atomic DB enforcement.
- Rate limiting — 60 challenges per minute per IP per sitekey.
- Automatic cleanup — opportunistic + Vercel daily cron + optional external.
- Admin panel — email/password login, site registration, secret rotation, live stats. Mobile-friendly.
- No build step — the widget is one file.
Each layer is independent. Removing one does not break the others.
- Signed challenges (HMAC) — forged or tampered challenges are rejected without a DB hit. The signature covers challenge + difficulty + issue time.
- Server-observed trusted signals — UA, headers, Origin, Referer, ASN, and server-measured timing. These come from the HTTP request itself and cannot be spoofed by a plain HTTP client.
- Consistency checks — cross-verifies client-claimed values against server-observed ones. Catches UA spoofing, platform mismatches, timezone disagreements, and impossible screen geometries.
- Environment fingerprinting — probes the JS environment for headless and automation markers. Checks the presence of Chrome-specific APIs, plugin/mimetype counts, WebGL renderer strings, window vs screen geometry, Permissions API availability, and User-Agent client hints.
- Behavioral telemetry — analyzes the event stream (mouse positions, key events, scroll) for scripted motion patterns. Catches linear mouse paths, zero-variance velocity, robotic keystroke rhythm, and batched event floods.
- Fingerprint reputation — persistent trust per browser fingerprint, tracked across solves. Bots that share fingerprints get penalized.
- IP reputation — persistent trust per IP. Repeat offenders face higher difficulty.
- ASN blocking — cloud infrastructure is capped at score 0.15.
- Atomic single-use tokens — replay is impossible.
- Rate limiting — 60 challenges/min per IP + sitekey, plus adaptive difficulty escalation during bursts.
git clone https://github.com/IROTECHLAB/irocap.git
cd irocapOpen https://console.neon.tech, create a project, and copy the pooled
connection string (hostname contains -pooler).
If it ends with &channel_binding=require, remove that
parameter before using it.
Open the Neon SQL Editor and paste the contents of
scripts/schema.sql. Click Run.
Verify all 8 tables were created:
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' ORDER BY table_name;Expected: admin_users, challenges,
fingerprints, ip_asn, ip_reputation,
irocap_tokens, rate_limits, sites.
Copy .env.example to .env and fill in the values.
See the Configuration reference for what each variable does.
Generate secrets with:
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"Generate a password hash and salt:
node -e "
const { randomBytes, scryptSync } = require('node:crypto');
const pw = process.argv[1];
const salt = randomBytes(16);
console.log('hash:', scryptSync(pw, salt, 64).toString('hex'));
console.log('salt:', salt.toString('hex'));
" 'your-password'In the Neon SQL Editor:
INSERT INTO admin_users (email, password_hash, password_salt, active)
VALUES (
'[email protected]',
'<hash from above>',
'<salt from above>',
TRUE
);vercel --prodOpen /admin.html to register your first site.
- Sign up at https://console.neon.tech
- Create a project
- Copy the pooled connection string
- Paste
scripts/schema.sqlinto the SQL Editor and Run
- Sign up at https://vercel.com
- Install the CLI:
npm i -g vercel - Link the project:
vercel link
vercel env add DATABASE_URL production
vercel env add IROCAP_JWT_SECRET production
vercel env add IROCAP_ADMIN_JWT_SECRET production
vercel env add IROCAP_CHALLENGE_SECRET production
vercel env add CRON_SECRET productionOptionally add IPINFO_TOKEN for ASN blocking (free tier of
ipinfo.io covers 50,000 lookups/month).
vercel --prodInsert the admin user via SQL (see Quick start step 4), then open
/admin.html and register a site.
Cleanup runs opportunistically inside /api/challenge and
/api/verify on about 2% of requests. The Vercel daily cron runs
at 03:00 UTC. For more frequent purging, add an external scheduler:
- cron-job.org — URL
https://your-deployment.vercel.app/api/cleanup, method GET, headerAuthorization: Bearer <CRON_SECRET>, every 10 minutes. - UptimeRobot — HTTP monitor on the same URL with the same header.
Access at /admin.html.
| Feature | Description |
|---|---|
| Sign in | Email + password, scrypt-hashed, 12-hour session JWT |
| Overview | Counters: active sites, 24h challenges, tokens, consumed |
| Register a site | Create a new sitekey + secret pair for a domain |
| Site cards | Sitekey, masked secret, created date, active state |
| Regenerate secret | Rotate the secret; the old one stops working immediately |
| Activate / Deactivate | Toggle whether the site accepts challenges |
| Delete | Remove the site and invalidate its keys |
The secret is write-only. To retrieve a new one, use Regenerate secret — it invalidates the old one and shows the new one exactly once.
<script src="https://your-deployment.vercel.app/irocap.js"></script>
<form action="/submit" method="POST">
<input type="email" name="email" required />
<div class="irocap"></div>
<button type="submit">Submit</button>
</form>
<script>
new Irocap('YOUR_SITEKEY', {
container: '.irocap'
});
</script>The widget inserts a hidden irocap-token field into the form.
const r = await fetch('https://your-deployment.vercel.app/api/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
secret: process.env.IROCAP_SECRET,
response: req.body['irocap-token'],
remoteip: req.ip
})
});
const result = await r.json();
if (!result.success || result.score < 0.5) {
return res.status(400).send('CAPTCHA failed');
}See INTEGRATION.md for framework-specific examples (Express,
Next.js, Fastify, Cloudflare Workers, PHP, Python, Go, Ruby, WordPress).
| Variable | Required | Purpose |
|---|---|---|
DATABASE_URL |
Yes | Neon pooled Postgres URL |
IROCAP_JWT_SECRET |
Yes | Signing key for post-solve tokens (5 min TTL) |
IROCAP_ADMIN_JWT_SECRET |
Yes | Signing key for admin sessions (12 h TTL) |
IROCAP_CHALLENGE_SECRET |
Yes | HMAC key for signing challenges |
CRON_SECRET |
Yes | Bearer token protecting /api/cleanup |
IPINFO_TOKEN |
No | Enables ASN lookup for datacenter blocking |
IROCAP_BASE_URL |
No | Public base URL used by scripts |
IROCAP_ADMIN_EMAIL |
No | Convenience for CLI scripts |
IROCAP_ADMIN_PASSWORD |
No | Convenience for CLI scripts |
| Option | Type | Required | Description |
|---|---|---|---|
sitekey |
string | Yes | Public sitekey from the admin panel |
container |
string or Element | Yes | Where the badge renders |
callback |
function | No | (token, score) => void |
error-callback |
function | No | (err) => void |
base |
string | No | Override the irocap base URL |
| Range | Meaning | Suggested action |
|---|---|---|
| 0.8 – 1.0 | Very likely human | Accept |
| 0.5 – 0.8 | Probably human | Accept |
| 0.3 – 0.5 | Mildly suspicious | Accept with rate limit |
| 0.0 – 0.3 | Likely bot | Reject |
Recommended default: score >= 0.5.
irocap/
├── api/ Vercel serverless functions
│ ├── challenge.ts Issue signed challenge
│ ├── verify.ts Verify PoW + score all layers + issue JWT
│ ├── siteverify.ts Site-owner verification endpoint
│ ├── cleanup.ts Cron-protected purge
│ ├── register.ts Admin: create a site
│ ├── login.ts Admin login
│ ├── logout.ts Admin logout
│ ├── sites.ts Admin: list sites
│ ├── stats.ts Admin: dashboard counters
│ └── site-manage.ts Admin: rotate / toggle / delete
├── lib/
│ ├── db.ts Neon HTTP client
│ ├── tokens.ts JWT sign/verify
│ ├── admin.ts scrypt password + admin session JWT
│ ├── scoring.ts Client-claimed signal scoring
│ ├── rate-limit.ts IP+sitekey throttle
│ ├── cleanup.ts Opportunistic purge
│ ├── challenge-sign.ts HMAC for challenges
│ ├── trusted.ts Server-observed signals
│ ├── consistency.ts Header/client cross-checks
│ ├── behavioral.ts Event-stream analysis
│ ├── env-signals.ts Environment / headless detection
│ ├── fingerprint.ts Fingerprint hashing
│ ├── asn.ts ASN lookup + datacenter blocklist
│ └── cors.ts CORS preflight helpers
├── public/
│ ├── irocap.js Single-file widget
│ ├── index.html Demo page
│ ├── admin.html Admin dashboard
│ └── wasm/
│ └── sha256.umd.min.js Self-hosted hash-wasm UMD
├── scripts/
│ ├── schema.sql Complete database schema
│ ├── create-admin.ts Password hash generator
│ ├── register-site.ts Register a site
│ └── e2e.ts End-to-end test
├── vercel.json
└── package.json
| Table | Purpose |
|---|---|
sites |
Registered domains with sitekey + secret |
challenges |
Issued challenges (UNLOGGED, ephemeral) |
irocap_tokens |
Issued JWTs with single-use flag |
rate_limits |
Sliding-window counters per IP + sitekey |
admin_users |
Admin accounts (scrypt hash) |
fingerprints |
Per-fingerprint reputation |
ip_reputation |
Per-IP trust scores |
ip_asn |
Cached ASN lookups |
We do not claim irocap is faster or slower than any other CAPTCHA. Solve time depends on the visitor's device, browser, and network. You should benchmark on your own traffic before making any decisions about UX.
What we can describe accurately:
- The primary solver runs in a Web Worker. It does not block the main thread regardless of how long the solve takes.
- There is no visible timer. The visitor sees a "Verifying…" badge and can continue filling out the form.
- A three-tier fallback chain (WASM → inline JS →
crypto.subtle) ensures the solve completes on modern browsers, at the cost of speed on older devices. - Total added latency to a form submission depends on:
- The visitor's CPU (hash rate)
- The challenge difficulty (default 5, meaning ~1 million average hash attempts)
- Whether WASM loaded successfully
What varies:
- Modern desktops and recent flagships typically solve in well under a second.
- Mid-range Android devices typically solve in a few seconds.
- Budget and older Android devices can take tens of seconds. The solve is silent — the badge shows "Verifying…" and the visitor can continue filling the form.
If solves feel too slow:
Lower the base difficulty in api/challenge.ts:
const BASE_DIFFICULTY = 5; // change to 4 for faster solvesEach step down halves the average solve time and halves the average bot cost. There is no free lunch.
The widget shows a score from 0.0 to 1.0 on every solve. Higher is better. A score of 1.0 means every check passed cleanly. Lower scores mean one or more signals contributed a penalty.
If you reproduce the demo on a slow device, you may see scores like 0.6 or 0.7 even though the solve succeeded and the token was issued. That is not a bug. It means one of the scoring rules fired based on something the server or the widget observed. The token is still valid, and your backend should still accept it if the score is above your threshold (default 0.5).
One rule in lib/scoring.ts penalizes solves that take too long in the
WASM path:
if (signals.method === 'wasm' && signals.solveMs > 30000) score -= 0.4;On a modern desktop, WASM at difficulty 5 solves in a few hundred milliseconds. On a 2020+ Android device, roughly 500 ms to 3 seconds. On older budget Android hardware (a Redmi Note 4, for example), it can take 30–40 seconds. When a solve takes over 30 seconds, the rule assumes WASM should have been faster and applies a −0.4 penalty. The final score becomes 0.6.
This is intentional: the rule exists to catch bots that claim to be using WASM but are somehow running much slower than a real browser would. It sometimes fires on legitimately slow devices, at the cost of a lower score.
You have three options:
1. Accept it. If your score threshold is 0.5 (the default), 0.6 passes and the solve is accepted. The visitor sees no difference.
2. Raise the threshold in lib/scoring.ts to be more tolerant of slow
devices. For example, change 30 seconds to 120 seconds:
if (signals.method === 'wasm' && signals.solveMs > 120000) score -= 0.4;This accepts slow budget devices without penalizing them, while still catching the truly impossibly-slow case.
3. Lower the challenge difficulty in api/challenge.ts:
const BASE_DIFFICULTY = 5; // change to 4 for ~2x faster solvesEach step down halves the average solve time and halves the average bot cost. There is no free lunch.
| Score | Typical cause |
|---|---|
| 1.0 | All checks passed |
| 0.9 | One soft penalty (e.g. an informational signal) |
| 0.6 | WASM timeout rule fired, or two soft penalties |
| 0.2 | ASN blocking fired (datacenter IP), or 2+ headless markers detected |
| 0.0 | navigator.webdriver === true, canvas blocklist hit, or a hard bot-detection rule |
Every penalty has a name in the server logs. To see exactly why a specific solve got a specific score, tail the deployment logs:
vercel logs https://your-deployment.vercel.app --followThen trigger a solve. Every rule that fires prints a line beginning with
[irocap], followed by the reason and the sitekey:
[irocap] env flags { sitekey: 'd835...', reasons: [ 'no-permissions-api' ] }
[irocap] consistency flags { sitekey: 'd835...', reasons: [ 'chrome-no-sec-ch-ua' ] }
[irocap] datacenter asn { sitekey: 'd835...', ip: '...', asn: 14618, org: 'Amazon' }
[irocap] bot rejected { sitekey: 'd835...', reasons: [ 'raw-http-client-ua' ] }
Each line maps directly to a rule in one of the lib/*.ts scoring
modules. Search for the reason string and you'll find the rule.
- Never expose the secret. Server-side environment variables only. Rotate immediately if leaked.
- Verify on the server. The widget's callback is a hint. Always call
/api/siteverifyfrom your backend. - Enforce a score threshold. A
success: truewith a low score still means a likely bot. Check both. - Single-use. Do not retry verification on failure. A retry burns the token.
- Rate limit your own endpoints. irocap handles CAPTCHA rate limiting; your signup/login endpoints still need their own.
- Log suspicious activity. A burst of
already-usederrors from one IP is a replay signal. - Never put the secret in client-side code.
Honest list of what irocap does not stop:
- Real-browser AI agents (Manus, Operator, Claude computer-use). These are indistinguishable from real users by design.
- Stealth-patched Puppeteer or Playwright on residential IPs.
- TLS-impersonating proxies (JA3/JA4 spoofing). Vercel abstracts the TLS layer, so we cannot inspect the handshake.
- Targeted, patient attackers who mimic human behavior slowly.
This is the same fundamental ceiling every client-side CAPTCHA has.
What irocap does stop:
- Plain HTTP clients (curl, requests, axios, Go http, etc.)
- Selenium / Puppeteer with default settings
- Headless Chrome without stealth patches
- Selenium with UA spoofing but no
sec-ch-ua - Browsers that claim Chrome but do not expose the Chrome API surface
- Datacenter-hosted bots
- Naive mouse/keyboard scripting
- Replay attacks and cross-site token reuse
- Brute-force flooding
For many sites, that covers the practical threat model.


