A little proof. A lot of privacy.
A self-hosted CAPTCHA alternative that makes automated requests more expensive
with browser proof of work. Visitors click a lightweight widget; a Web Worker
solves the challenges and your server verifies the result.
- No visual puzzles: verification runs in a Web Worker.
- Privacy first: no third-party verification service or tracking cookies.
- Self-hosted: your application owns its signing keys and verification API.
- Zero runtime dependencies: ESM modules with TypeScript declarations.
- Flexible security: scoped tokens, context binding, key rotation, and opt-in one-time challenge and token consumption.
- Portable: Node HTTP and Express adapters, plus Web Crypto and Fetch APIs.
- Customizable widget: themes, translated messages, progress, background solving, cancellation, timeouts, and a separately hosted worker option.
Proof of work raises the cost of automation. It does not establish that a visitor is human. Combine it with application validation, sensible rate limits, and monitoring.
npm install rootcapUse Node.js 24 or later for the Node APIs. The widget requires a modern browser with Web Workers, Web Crypto, and form-associated custom elements, served over HTTPS or localhost. Browser imports below assume a frontend bundler such as Vite.
This example protects a same-origin signup form with the signup scope.
Generate a secret for your server environment:
export ROOTCAP_SECRET=$(node -e "console.log(require('node:crypto').randomBytes(48).toString('hex'))")Store it in your deployment's secret manager and share it among your API instances. Keep it out of frontend bundles and source control. The adapters require at least 16 characters.
Install Express if your application does not already use it:
npm install expressimport express from "express";
import { verifyToken } from "rootcap";
import { createExpressMiddleware } from "rootcap/express";
const secret = process.env.ROOTCAP_SECRET;
if (!secret || secret.length < 16) throw new Error("Set ROOTCAP_SECRET");
const app = express();
// Mount before body parsers: the adapter reads and bounds its own API bodies.
app.use(createExpressMiddleware({ secret, scope: "signup" }));
app.use(express.json({ limit: "64kb" }));
app.use(express.urlencoded({ extended: false, limit: "64kb" }));
app.post("/signup", (req, res) => {
const token = req.body?.["rootcap-token"];
if (typeof token !== "string" || !verifyToken(secret, token, { scope: "signup" })) {
return res.status(403).json({ error: "verification_failed" });
}
// Validate the remaining fields and perform your protected operation here.
return res.json({ success: true });
});
app.listen(3000);The adapter serves /api/challenge and /api/verify. It also exposes
process-local /api/stats; restrict that endpoint in your application or proxy
if you do not want to expose those counters. Serve your frontend on the same
origin, or configure CORS and the form action for your deployment.
Import the custom element in your frontend entry module:
import "rootcap/widget";<form action="/signup" method="POST">
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<rootcap-widget data-scope="signup" name="rootcap-token" required></rootcap-widget>
<button type="submit">Sign up</button>
</form>The widget contributes the redeem token to the form. Always verify it on the
server before accepting the protected action. The server controls the scope;
data-scope is not an authorization boundary. Set data-api when the API uses a
different base URL.
- The server signs a batch of short-lived challenges.
- The widget solves them in a worker and posts the solutions to your API.
- Your API verifies the proof and returns a redeem token.
- Your protected application route verifies that token and its expected scope.
By default, verification is stateless and tokens remain reusable until expiry.
Use createProtection with an atomic shared store when your application requires
one-time use. Both proof verification and final redemption must use that same
protection configuration.
| Capability | API or configuration |
|---|---|
| One-time challenges and redeem tokens | createProtection({ keys, store, consumeChallenges, consumeTokens }) |
| Signing-key rotation | Put the active key first in keys; retain previous keys while their tokens remain valid |
| Action and request-context binding | Match server-controlled scope and context when issuing and verifying |
| Shared replay protection | createRedisConsumptionStore(client) with atomic Redis consumption |
| Shared rate limiting | createRedisRateLimiter(client, { limit, windowMs }) and adapter rate-limit hooks |
| Workload presets | securityProfiles.light, .balanced, and .strict |
| Operational telemetry | Adapter onEvent callback and application monitoring |
Redis helpers accept a client with sendCommand; install and configure your
chosen Redis client separately. Store failures reject verification. Choose rate
limit identities using trusted proxy configuration and keep replay state shared
across application instances.
Challenge defaults are 50 puzzles, difficulty 4, and a 10-minute lifetime. Redeem tokens default to 20 minutes. Tune these for your traffic and representative client devices. Memory-hard proofs remain research for a future protocol.
const widget = document.querySelector("rootcap-widget");
await widget.execute();
const token = widget.getResponse();
widget.reset();Use cancel() to stop active verification, data-timeout to bound elapsed time,
and data-max-work to limit the estimated work accepted from the server.
data-auto starts verification when the associated form receives focus.
Listen for rootcap:verified, rootcap:progress, rootcap:error,
rootcap:expired, and rootcap:reset. Customize messages through the messages
property, and theme the element with CSS variables:
rootcap-widget {
--rc-primary: #4f46e5;
--rc-verified-surface: #eef2ff;
--rc-on-primary: #ffffff;
--rc-focus: #3730a3;
--rc-radius: 8px;
}See the widget reference for attributes, events, and theming.
| Import | Purpose |
|---|---|
rootcap |
Synchronous Node core, protection, profiles, and Redis helpers |
rootcap/widget |
Registers the <rootcap-widget> custom element |
rootcap/server |
Node HTTP createApiHandler |
rootcap/express |
Express-compatible createExpressMiddleware |
rootcap/edge |
Asynchronous Web Crypto core and createFetchHandler |
rootcap/worker/solver.js |
Worker asset for separately hosted deployments |
The website documentation covers installation, the core API, widget configuration, and deployment. Exercise integrations on your target runtime.
npm ci
npm run devThe website and development API run at http://localhost:3000. A development-only
fallback secret is provided locally; production requires ROOTCAP_SECRET.
sudo apt-get update && sudo apt-get install -y redis-server redis-tools
npm run check
ROOTCAP_REDIS_INTEGRATION=1 npm test
bash scripts/install-demo.sh
npm run build --prefix demo
npm test --prefix demonpm run check runs formatting, lint, type checks, unit tests, and website,
library, and declaration builds. Redis integration tests require redis-server
on PATH. The standalone demo script builds and installs the exact package archive
before testing it; see demo setup.
npm run bench:pow -- 20
npm pack --dry-runThe benchmark reports local Node timings for each profile. Measure real browser performance before selecting production settings. Package creation rebuilds the library and declarations and excludes website assets.