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.
bot-detectionA deterministic, zero-false-positive way to stop stealth scrapers, built into the WebDecoy Node SDK. Runs entirely in your app, no account required.
WebDecoy Team
WebDecoy Security Team
For about a decade, bot detection has mostly been a game of identity. You analyze a request’s fingerprint, the User-Agent, the TLS handshake (JA3/JA4), the headless-browser tells, navigator.webdriver, the canvas and WebGL quirks, and you decide: human or bot?
That game is getting harder to win. A new generation of scraping frameworks is purpose-built to beat it. Tools like botasaurus, undetected-chromedriver, and Playwright-stealth drive a real Chrome, strip the automation flags, spoof the fingerprint, and route through residential proxies. Independent testing puts residential-proxy scrapers past the major CDNs’ bot management 80 to 99 percent of the time.
We tested this directly. We pointed a real botasaurus browser session at a full fingerprint-detection engine, the same one that powers our open-source captcha: worker and main-thread consistency checks, software-WebGL detection, audio-context analysis, native-function tamper detection, the works. The result:
Real botasaurus, browser mode: scored ~30 percent, allowed. It tripped essentially none of the fingerprint checks. It looks like a real browser because it is a real browser.
That is not a bug in the detector. It is the nature of the fight. If a tool can present a genuine browser fingerprint, fingerprinting cannot reliably tell it apart from your actual users. This is the same reason we don’t rely on machine-learning fingerprint classifiers alone: a probability score is only as good as the signal underneath it, and stealth tooling erases the signal.
Here is the key insight. You may not be able to tell a stealth scraper from a human by how it looks, but you can tell them apart by what they do.
A human navigates your site by looking at it. They click visible links, read visible content, and fill visible forms. A scraper navigates by parsing your HTML and following every link it finds, including links a human would never see.
A tripwire weaponizes that difference. You plant a honeytoken: a hidden link pointing at a secret path, invisible to humans (off-screen, aria-hidden, nofollow). Real users never see it, so they never request it. But a scraper that harvests links from your HTML will follow it, and the moment it requests that path, you know, with certainty, that it is automated.
This flips the economics of the whole problem:
We validated this against the same real botasaurus that beat fingerprinting:
Real botasaurus crawler, hit the tripwire, 403 Blocked. The same tool that walked past the fingerprint engine was stopped cold. Meanwhile a real Chrome browser loading the same page never requested the hidden link. Zero false positives.
The tool built to defeat fingerprinting has no answer for a tripwire.
If you have read our work on honeypot placement beyond CSS hiding, this is the enforcement counterpart. That post is about where to hide a trap so vision-based agents miss it; this is the SDK rule that acts on the hit.
This is the part worth underlining: tripwires are not a hosted service. The tripwire rule and honeytoken generator ship in the open-source @webdecoy/node SDK (MIT), and they run entirely in your process:
You npm install, add a rule, and you are blocking scrapers. That is the whole thing. It is the same in-process philosophy behind the self-hosted captcha and detection engine we shipped in v0.3.0: the decision happens on your hardware, not ours.
A WebDecoy subscription is optional, and it only adds things a local library cannot do on its own: reporting tripwire hits to a dashboard for analytics and cross-site intelligence, IP-reputation filtering (filter({ expression: 'ip.tor or ip.vpn' })), and hosted server-side fingerprint and TLS analysis. The enforcement, the actual blocking, is 100 percent free and local.
Two pieces:
honeytoken() generates a hidden decoy link and the secret path it points at:const trap = honeytoken();
// trap.path -> "/__wd/9f3a1c8b2d4e" (register this as a tripwire)
// trap.linkHtml -> '<a href="/__wd/9f3a1c8b2d4e" aria-hidden="true"
// tabindex="-1" rel="nofollow noindex"
// style="position:absolute;left:-9999px;...">.</a>'tripwire({ paths: [trap.path] }) is a rule that blocks any request to a registered honeypot path, plus a built-in list of common scanner-bait paths like /.env and /.git/config.The request flow:
Scraper fetches your page
│
▼
Parses HTML, extracts links → finds the hidden honeytoken link
│
▼
Follows it: GET /__wd/9f3a1c8b2d4e
│
▼
WebDecoy tripwire rule → DENY → 403 BlockedA real user’s browser renders the page, never sees the off-screen link, and never makes that request.
Install the core SDK:
npm install @webdecoy/node@webdecoy/node (framework-agnostic core)Use this directly if you are not on a supported framework, or want full control:
import { WebDecoy, tripwire, honeytoken } from '@webdecoy/node';
// 1. Generate a honeytoken (hidden link + its secret path)
const trap = honeytoken();
// 2. Register the path as a tripwire (no apiKey needed)
const webdecoy = new WebDecoy({
rules: [
tripwire({ paths: [trap.path] }), // + built-in scanner-bait paths
],
});
// 3. Check each request
const result = await webdecoy.protect({
method: req.method,
path: req.url,
ip: req.socket.remoteAddress,
user_agent: req.headers['user-agent'],
headers: req.headers,
timestamp: Date.now(),
});
if (!result.allowed) {
res.writeHead(403).end('Forbidden'); // scraper tripped the wire
return;
}
// 4. Inject the decoy link into your HTML so scrapers can find it
html = html.replace('</body>', `${trap.linkHtml}</body>`);That is a complete, self-contained scraper trap: no account, no external calls.
@webdecoy/expressnpm install @webdecoy/expressimport express from 'express';
import { webdecoy } from '@webdecoy/express';
import { tripwire, honeytoken } from '@webdecoy/node';
const app = express();
const trap = honeytoken();
// One line protects every route. A tripwire hit → automatic 403.
app.use(
webdecoy({
rules: [tripwire({ paths: [trap.path] })],
skipPaths: ['/health', '/static'],
})
);
// Inject the decoy link into your rendered pages
app.get('/', (req, res) => {
res.send(`<!doctype html><html><body>
<h1>Welcome</h1>
${trap.linkHtml}
</body></html>`);
});
app.listen(3000);@webdecoy/fastifynpm install @webdecoy/fastifyimport Fastify from 'fastify';
import webdecoy from '@webdecoy/fastify';
import { tripwire, honeytoken } from '@webdecoy/node';
const fastify = Fastify();
const trap = honeytoken();
await fastify.register(webdecoy, {
rules: [tripwire({ paths: [trap.path] })],
skipPaths: ['/health'],
});
fastify.get('/', async (request, reply) => {
reply.type('text/html');
return `<!doctype html><html><body>
<h1>Welcome</h1>
${trap.linkHtml}
</body></html>`;
});
await fastify.listen({ port: 3000 });@webdecoy/nextjsnpm install @webdecoy/nextjsProtect routes from the edge in middleware.ts. This slots straight into the setup from our Next.js edge bot-detection guide:
// middleware.ts
import { withWebDecoy } from '@webdecoy/nextjs';
import { tripwire, honeytoken } from '@webdecoy/node';
// Keep a stable token so the path is consistent across edge invocations
const trap = honeytoken({ token: process.env.WD_HONEYTOKEN });
export default withWebDecoy({
rules: [tripwire({ paths: [trap.path] })],
skipPaths: ['/_next', '/favicon.ico'],
});
export const config = {
matcher: ['/((?!_next/static|_next/image).*)'],
};Then render the decoy link in your layout (app/layout.tsx):
export const HONEYTOKEN_PATH = `/__wd/${process.env.WD_HONEYTOKEN}`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
{/* invisible decoy link — humans never see it */}
<a
href={HONEYTOKEN_PATH}
aria-hidden="true"
tabIndex={-1}
rel="nofollow noindex"
style={{ position: 'absolute', left: '-9999px', width: 1, height: 1, overflow: 'hidden' }}
>
.
</a>
</body>
</html>
);
}On serverless and edge (Next.js, Vercel), pass a fixed
tokentohoneytoken()so every invocation agrees on the same path.
tripwire() accepts more than a single path:
tripwire({
// exact hidden paths (your honeytokens, or specific bait)
paths: [trap.path, '/admin-backup.zip'],
// any path under these prefixes
prefixes: ['/.git/', '/.aws/'],
// regex patterns
patterns: [/\/wp-admin\//, /\.sql$/],
// built-in scanner-bait paths: /.env, /.git/config, /wp-config.php,
// /backup.zip, /phpinfo.php, ... (default: true)
includeDefaults: true,
// 'DENY' (block, default) or 'THROTTLE' (rate-limit response)
action: 'DENY',
// log matches but don't block — useful to observe before enforcing
dryRun: false,
});Tip: observe before you enforce. Ship with dryRun: true first, watch your logs for hits on the built-in bait paths, confirm you see only bots, then flip it off.
Tripwires are one of the SDK’s local rules. They compose with rateLimit(), also fully standalone, no API key:
import { WebDecoy, tripwire, rateLimit, honeytoken } from '@webdecoy/node';
const trap = honeytoken();
const webdecoy = new WebDecoy({
rules: [
rateLimit({ max: 100, window: 60 }), // 100 req / 60s per IP
tripwire({ paths: [trap.path] }), // instant block on trap hit
],
});Rules evaluate in order; the first DENY or THROTTLE wins.
<body>) so link-following crawlers reach it. Our guide on honeypot traps for forms, buttons, and endpoints goes deeper on placement.robots.txt Disallow. Add Disallow: /__wd/ and register that prefix as a tripwire. Polite bots avoid it, and aggressive scrapers that mine Disallow entries for “interesting” paths walk right in.honeytoken() again) so a scraper cannot hard-code an avoid-list.The library blocks scrapers on its own, in your process, on the one path in front of it. Connect an API key and every tripwire hit also becomes an event your wider stack can act on:
filter({ expression: 'ip.tor or ip.vpn or ip.hosting' }).The through-line: the SDK gives you the deterministic signal and a local block, and the platform turns that signal into stack-wide action. Edge blocking that keeps the scraper off every route, and a logged event your SOC can see.
But to be clear: the trap, and the block, are free and local. The subscription is intelligence and reach on top.
You cannot out-fingerprint a tool that is engineered to look human. But you do not have to. You can catch it by what it does: a hidden link a human never sees, a secret path a scraper cannot resist, and a deterministic block the moment it bites. Zero false positives, no account, a few lines of code.
npm install @webdecoy/nodeimport { tripwire, honeytoken } from '@webdecoy/node';Full reference and runnable examples live on the SDK product page and in the GitHub repository. Go set a trap.
Fingerprinting decides whether a client is a bot by how it looks: User-Agent, TLS handshake (JA3/JA4), headless tells, canvas and WebGL quirks. Stealth scrapers defeat this by driving a real browser and spoofing every signal. A tripwire ignores identity and looks at behavior. It plants a hidden link that only a client parsing your HTML would ever follow, so a request to that path is a deterministic fact that the client is automated, not a probability score.
No, provided the decoy link is genuinely hidden. The only way to reach the honeytoken path is to parse the HTML and follow a link that is off-screen, aria-hidden, and marked nofollow. A human never sees it, and a search engine following your visible links never touches it. That is why tripwires are effectively zero false positive: a real browser renders the page and never requests the trap.
No. The tripwire rule and the honeytoken generator ship in the open-source @webdecoy/node package (MIT) and run entirely in your process. There is no API key, no account, and no network call. Tripwire matching is a local path comparison. A WebDecoy subscription is optional and only adds reporting, IP-reputation filtering, and hosted deep detection on top of the free local block.
It can only avoid a path it has already seen. Because you generate honeytokens dynamically and can rotate them by calling honeytoken() again, a scraper cannot hard-code a reliable avoid-list. The built-in scanner-bait paths (like /.env and /.git/config) add a second layer that catches vulnerability scanners regardless of your rotating token.
They are complementary layers. Tripwires are the deterministic catch for link-following scrapers. Rate limiting throttles high-volume abuse that does not crawl, and server-side fingerprinting plus IP reputation catch bots that hit your endpoints directly without following links. In the SDK, tripwire() and rateLimit() compose as ordered rules, and adding a WebDecoy key layers hosted detection on top.
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.
bot-detectionAn honest argument against ML-driven bot detection. Adversarial drift, training data poisoning, latency, and the explainability tax that nobody talks about.
bot-detectionBot Scanner Pro detects Stagehand, Browserbase, and Playwright with stealth plugins using behavioral analysis and deep fingerprinting. 60-75% detection rate.
bot-detectionLike this post? Share it with your friends!
Get a personalized demo from our team.