Self-Hosted Captcha for Node.js: WebDecoy SDK v0.3.0
WebDecoy Node SDK v0.3.0 ships a self-hosted proof-of-work captcha and an in-process detection engine that scores ~40 signals with no third-party calls.
WebDecoy Team
WebDecoy Security Team
Self-Hosted Captcha for Node.js: WebDecoy SDK v0.3.0
The Node SDK started life as a thin client. You called protect(), it sent request metadata to our API, and you got back a verdict. That model works, and it is still there. But it has two costs that matter to a lot of teams: every protected request makes a network round trip, and the request data leaves your server to get scored.
Version 0.3.0 removes both costs. The detection now runs in your own process. The SDK ships the full scoring engine and a self-hosted, proof-of-work captcha, so the decision to allow, challenge, or block a request happens on your hardware with no remote call. The only thing that still talks to api.webdecoy.com is optional IP enrichment, and you can turn that off.
This post walks through what shipped: the in-process detection engine, the captcha service, the new browser widget, and how to wire all three together. Code throughout, real APIs, no marketing.
What shipped in v0.3.0
Three pieces, all published to npm at 0.3.0:
DetectionEnginein@webdecoy/node. An in-process scorer that takes the signals collected in the browser and runs them through a stack of detectors, returning anallow,challenge, orblockverdict. No network call.Captchain@webdecoy/node. A self-hosted captcha service built on HMAC-signed proof-of-work challenges and single-use, IP-bound session tokens.@webdecoy/client, a brand new package. A browser widget that collects roughly 40 behavioral, environmental, and fingerprint signals, solves the proof-of-work, and submits the result to your server. It runs as a checkbox, as an invisible passive scorer, or on demand.
The detection engine is the same one that powers FCaptcha, our open-source captcha, ported into the SDK so you can run it directly from your backend.
Why in-process matters
The API-backed protect() flow is convenient, but an in-process engine changes the trade-offs in three ways.
Latency. There is no per-request round trip to a detection service. The score is computed locally, in the same event loop as your handler, so the cost is CPU time measured in milliseconds rather than a network hop.
Privacy and data residency. The signals and the scoring stay on your server. For teams with data-residency requirements, or anyone who would simply rather not ship visitor telemetry to a third party, this is the difference between “allowed” and “not allowed.” It is also why this is a real alternative to the captchas that are quietly dying: you are not embedding a Google or Cloudflare endpoint that watches your users.
Control. Because the model runs in your process, you can read the per-category scores, tune the weights, and decide your own thresholds rather than accepting an opaque verdict.
IP reputation is the one signal that genuinely needs outside data (you cannot know an address is a known proxy or Tor exit from inside your process), so that piece still calls our IP-enrichment API. It is optional and separate from the core scoring.
The detection engine
DetectionEngine scores signals across a wide set of detectors: vision-AI agents, headless browsers, automation and CDP markers, behavioral patterns, mobile touch and sensor entropy, fingerprint consistency, header anomalies, browser-consistency checks, JA3 and JA4 hints, form-interaction stats, and keystroke cadence. Several of these have their own deep dives, including headless browser detection, JA4 fingerprinting, and keystroke cadence biometrics.
You can call it directly when you want full control over the verdict:
import { DetectionEngine } from '@webdecoy/node';
const engine = new DetectionEngine({ requirePoW: false });
const verdict = engine.score(signals, {
ip,
siteKey: 'site',
userAgent,
headers,
});
// verdict: {
// success,
// score,
// recommendation: 'allow' | 'challenge' | 'block',
// categoryScores,
// detections,
// }One important property of the model: it is confidence-weighted across categories, so no single signal can cross the block threshold on its own. A missing proof-of-work on an otherwise clean request returns challenge, not block. That is deliberate. Hard-blocking on one weak signal is how you end up turning away real users. If you need to shift that balance, the weights option tunes the contribution of each category.
The self-hosted captcha
The Captcha service turns the engine into a complete challenge-response flow without any third-party widget.
It works like this. The browser asks your server for a challenge. Your server issues an HMAC-signed proof-of-work challenge with a difficulty target that can scale under load. The browser collects its signals, hashes them, finds a SHA-256 nonce that satisfies the difficulty, and submits the solution with the signals bound in. Your server verifies the work, confirms the signals hash matches what was committed, scores the signals with the detection engine, and on success issues a single-use session token bound to the client IP.
That binding is what makes it hard to game. An attacker cannot solve the proof-of-work once and replay it, because tokens are single-use and IP-bound. They cannot solve the work with clean signals and then submit dirty ones, because the signals are committed into the challenge before solving. If you want the background on why proof-of-work raises the cost of abuse, we wrote about it in detail in proof-of-work captcha with hashcash.
The browser widget
@webdecoy/client is the front-end half. Install it, or drop in the standalone bundle with a script tag.
npm install @webdecoy/clientThe checkbox widget is the familiar pattern:
<div id="captcha-box"></div>
<script type="module">
import { WebDecoyCaptcha } from '@webdecoy/client';
WebDecoyCaptcha.configure({ serverUrl: 'https://your-server.com' });
WebDecoyCaptcha.render('captcha-box', {
siteKey: 'pk_live_...',
theme: 'light',
callback: (token) => console.log('verified:', token),
});
</script>If you would rather not write any JavaScript, the widget auto-initializes from data attributes:
<div data-webdecoy="pk_live_..." data-endpoint="https://your-server.com"></div>
<script src="https://unpkg.com/@webdecoy/client/dist/webdecoy.global.js"></script>Invisible mode is the more interesting option. It passively scores the session and protects form submissions on its own, injecting a hidden webdecoy_token field and scoring at submit time:
import { WebDecoyCaptcha } from '@webdecoy/client';
WebDecoyCaptcha.configure({ serverUrl: 'https://your-server.com' });
WebDecoyCaptcha.invisible({ siteKey: 'pk_live_...' });Or score on demand for a specific action:
const result = await WebDecoyCaptcha.execute('pk_live_...', { action: 'login' });
if (result.success) {
// result.token is ready to submit
}The signals it collects span four groups: behavioral (mouse trajectory and micro-tremor, click precision, touch kinematics, scroll, keystroke cadence), sensor (device motion and orientation entropy on mobile), environmental (WebDriver, CDP, and Playwright markers, canvas, WebGL, audio, fonts, WebRTC, and more), and form interaction (submit method and per-field typing stats). All of it is hashed and bound into the proof-of-work, so it cannot be tampered with after the work is solved.
Wiring up the server
Mount the matching endpoints on your backend. For Express:
import express from 'express';
import { webdecoyCaptcha } from '@webdecoy/express';
const app = express();
app.use(express.json());
app.use(
webdecoyCaptcha({
secret: process.env.WEBDECOY_SECRET, // required in production
}),
);That serves the four endpoints the widget talks to, under the default base path /__webdecoy: GET /__webdecoy/challenge, POST /__webdecoy/verify, POST /__webdecoy/score, and POST /__webdecoy/token/verify. Fastify (webdecoyCaptchaPlugin) and Next.js (createCaptchaHandler) have their own adapters with the same shape, so the Next.js bot-detection setup gains a self-hosted captcha layer too.
Then, on the route you actually want to protect, verify the token the form submitted:
import { Captcha } from '@webdecoy/node';
const captcha = new Captcha({ secret: process.env.WEBDECOY_SECRET });
app.post('/login', (req, res) => {
const result = captcha.verifyToken(req.body.webdecoy_token, req.ip);
if (!result.valid) {
return res.status(403).json({ error: 'captcha failed' });
}
// proceed with login
});There is a runnable version of exactly this flow in the repo under examples/captcha-express if you want to see it end to end.
Deployment notes
A few things to get right before this goes to production.
Set a real secret. The secret signs both challenges and tokens. It is required when NODE_ENV=production, and a missing or default secret throws on purpose. Generate one and keep it out of source control:
openssl rand -hex 32Pick a store for your topology. The challenge, token, and fingerprint stores are in-memory by default, which is correct for a single long-running server. For serverless or multi-instance deployments, pass a shared challengeStore and tokenStore. The ChallengeStore and TokenStore interfaces are where Redis plugs in. If you skip this on a multi-instance setup, a challenge issued by one instance will not be found by another.
Decide on IP enrichment. Leave it on if you want VPN, proxy, Tor, and abuse-score signals folded into the verdict, or off if you want a fully local pipeline. It is the only piece that makes an outbound call.
Where this fits
If you have been looking for a captcha you can host yourself, this is it. It is a credible alternative to reCAPTCHA, hCaptcha, and Turnstile for teams that do not want a third-party script watching their users, and it shares its detection core with FCaptcha so the signal quality is the same one we ship in the open-source product. It also slots in next to the existing API-backed TLS fingerprinting flow rather than replacing it, so you can run in-process scoring on your forms and keep server-side verification where it makes sense.
All five packages are live at 0.3.0: @webdecoy/node, @webdecoy/client, @webdecoy/express, @webdecoy/fastify, and @webdecoy/nextjs.
npm install @webdecoy/node @webdecoy/clientThe full reference and the runnable example are on the SDK product page and in the GitHub repository. If you want the managed, API-backed path instead, you can still start for free.
Share this post
Like this post? Share it with your friends!
Want to see WebDecoy in action?
Get a personalized demo from our team.