Skip to content
 
 

Repository files navigation

F***Captcha

Open source CAPTCHA that blocks bots, vision AI agents, and automation - with a single click or less.

License Go Python Node Docker

Try the Live Demo

Deploy to Render Deploy on Railway

FCaptcha is a modern CAPTCHA system designed to detect everything: traditional bots, headless browsers, automation frameworks, CAPTCHA farms, and the new generation of AI agents — from vision models that screenshot-and-click to computer-use agents that drive a real browser over the Chrome DevTools Protocol.

Features

  • Single click or invisible - Checkbox mode like Turnstile/reCAPTCHA v2, or invisible mode like reCAPTCHA v3
  • AI agent detection - Catches vision agents (screenshot→API→click), DOM/CDP-driven agents (Claude in Chrome, Operator-style computer use), and synthetic input that reports isTrusted: true — via input-event forensics and LLM think-time cadence
  • Declared & verified agents - Flags self-declaring agents (ClaudeBot, GPTBot, ChatGPT-User, PerplexityBot, Bytespider…) and cryptographically verifies Web Bot Auth (RFC 9421) signed requests against the agent's published key directory, surfaced as a distinct category so your app can allow polite/verified agents and block the rest
  • Proof of Work - Server-verified SHA-256 hashcash with 256-bit HMAC signing, per-challenge nonces, and signal commitment that binds the challenge to the collected signals. A liveness and timing gate rather than a cost function — see what it does and does not buy you
  • Comprehensive bot detection - Headless browsers, WebDriver, Puppeteer, Playwright, Selenium, plus CDP console-attach detection
  • Behavioral biometrics - 40+ signals including micro-tremor, velocity/acceleration curves, trajectory analysis, coalesced pointer events, and teleport-click detection
  • Mobile-native - Touch kinematics and passive device-sensor entropy, with accessibility exemptions for keyboard-only and touch users
  • TLS fingerprinting - JA3 (client-supplied) and JA4 (un-spoofable, from a trusted reverse proxy) matched against known automation tools
  • Credential stuffing protection - Form interaction analysis, timing, and programmatic submit/fill detection
  • Self-hosted & privacy-first - No external dependencies, no persistent fingerprinting, no cross-site tracking
  • Input forensics - Typing cadence and modality (keystrokes vs. paste vs. a value assigned into the DOM), scroll morphology, and platform contradictions like a Ctrl+V paste from a browser claiming macOS
  • Open algorithm - Transparent, evidence-accumulating scoring across ~12 categories, fully auditable
  • Measured, not asserted - A benchmark harness captures real browser traces for 14 human personas (keyboard-only, screen-reader, touch, tremor, elderly, DevTools-open, privacy-extension…) and reports a per-signal false-positive budget in CI. Every threshold in the behavioural layer was derived from it
  • Multi-language servers - Go, Python, or Node.js, kept in lockstep

Quick Start

Docker (recommended)

One command to deploy:

docker run -d -p 3000:3000 -e FCAPTCHA_SECRET=my-secret ghcr.io/webdecoy/fcaptcha

This gives you:

  • API at http://localhost:3000/api/*
  • Client JS at http://localhost:3000/fcaptcha.js
  • Demo page at http://localhost:3000/demo/

With Redis (for distributed state):

FCAPTCHA_SECRET=my-secret docker compose -f docker/docker-compose.yml up -d

Deploy to Fly.io:

fly launch --copy-config
fly secrets set FCAPTCHA_SECRET=my-secret

Build from source:

docker build -f docker/Dockerfile -t fcaptcha .
docker run -d -p 3000:3000 -e FCAPTCHA_SECRET=my-secret fcaptcha

Run from Source

Pick your language:

Go (fastest)

cd server-go
go build -o fcaptcha-server
FCAPTCHA_SECRET=your-secret ./fcaptcha-server

Python (FastAPI)

cd server-python
pip install -r requirements.txt
FCAPTCHA_SECRET=your-secret python server.py

Node.js (Express)

cd server-node
npm install
FCAPTCHA_SECRET=your-secret node server.js

2. Add to Your Site

Checkbox Mode (Interactive)

<script src="https://your-server.com/fcaptcha.js"></script>
<div id="captcha"></div>

<script>
  FCaptcha.configure({ serverUrl: 'https://your-server.com' });

  FCaptcha.render('captcha', {
    siteKey: 'your-site-key',
    callback: (token) => {
      document.getElementById('token').value = token;
    }
  });
</script>

Invisible Mode (Zero-Click)

<script src="https://your-server.com/fcaptcha.js"></script>

<script>
  FCaptcha.configure({ serverUrl: 'https://your-server.com' });

  // Auto-protect all forms
  FCaptcha.invisible({
    siteKey: 'your-site-key',
    autoScore: true
  });

  // Or manually score specific actions
  const result = await FCaptcha.execute('your-site-key', {
    action: 'login'
  });

  if (result.score < 0.5) {
    // Likely human
  }
</script>

React (no library required)

The widget exposes a global API, so a small hook is all you need — no wrapper package to install or maintain.

import { useEffect, useState, useCallback, useRef } from 'react';

function useFCaptcha({ serverUrl, siteKey }) {
  const [ready, setReady] = useState(typeof window !== 'undefined' && !!window.FCaptcha);

  useEffect(() => {
    if (window.FCaptcha) {
      window.FCaptcha.configure({ serverUrl });
      setReady(true);
      return;
    }
    const script = document.createElement('script');
    script.src = `${serverUrl}/fcaptcha.js`;
    script.async = true;
    script.onload = () => {
      window.FCaptcha.configure({ serverUrl });
      setReady(true);
    };
    document.head.appendChild(script);
    return () => { script.remove(); };
  }, [serverUrl]);

  const execute = useCallback(
    (action) => window.FCaptcha.execute(siteKey, { action }),
    [siteKey]
  );

  return { ready, execute };
}

// Invisible mode — get a token at submit time
function LoginForm() {
  const { ready, execute } = useFCaptcha({
    serverUrl: 'https://your-server.com',
    siteKey: 'your-site-key',
  });

  async function onSubmit(e) {
    e.preventDefault();
    const { token } = await execute('login');
    await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: e.target.email.value, fcaptchaToken: token }),
    });
  }

  return (
    <form onSubmit={onSubmit}>
      <input name="email" type="email" required />
      <button disabled={!ready}>Sign in</button>
    </form>
  );
}

// Checkbox mode — render the interactive widget
function FCaptchaCheckbox({ siteKey, serverUrl, onVerify }) {
  const ref = useRef(null);
  const { ready } = useFCaptcha({ serverUrl, siteKey });

  useEffect(() => {
    if (!ready || !ref.current) return;
    const widgetId = window.FCaptcha.render(ref.current, { siteKey, callback: onVerify });
    return () => window.FCaptcha.reset(widgetId);
  }, [ready, siteKey, onVerify]);

  return <div ref={ref} />;
}

The same pattern works in Vue, Svelte, Solid, and Angular — the widget is framework-agnostic. If you'd rather not write the glue, opening an issue describing how you want to consume it helps us decide whether to ship an official wrapper.

3. Verify on Your Backend

// Go
resp, _ := http.Post("https://your-server.com/api/token/verify",
    "application/json",
    strings.NewReader(`{"token": "...", "secret": "your-secret"}`))

var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)

if result["valid"].(bool) && result["score"].(float64) < 0.5 {
    // Valid request from human
}
# Python
import requests

result = requests.post('https://your-server.com/api/token/verify',
    json={'token': '...', 'secret': 'your-secret'}
).json()

if result['valid'] and result['score'] < 0.5:
    # Valid request from human
// Node.js
const result = await fetch('https://your-server.com/api/token/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ token: '...', secret: 'your-secret' })
}).then(r => r.json());

if (result.valid && result.score < 0.5) {
  // Valid request from human
}

How It Works

FCaptcha collects signals across many categories and blends them into a single score (category weights sum to 1.0 and are tunable per deployment). The major surfaces:

Proof of Work (Invisible Layer)

Before any verification, clients must solve a SHA-256 hashcash challenge:

  • Challenge fetched on page load - solving runs in the background across parallel Web Workers (one per ~2 CPU cores)
  • Non-blocking - users never see it, computation happens while they fill forms
  • Hardened - 256-bit HMAC-signed challenges, one-time use, replay-protected, with a server-generated per-challenge nonce the client must echo back
  • Signal commitment - the client hashes its collected signals into the PoW input (prefix:signalsHash:nonce) and the server verifies the signals weren't tampered with after solving
  • Difficulty scaling - datacenter IPs and high-rate requesters get harder puzzles
  • Costs a real browser ~100-600ms - measured: 0.12s on a desktop, 0.58s on a throttled low-end phone

What the proof of work does and does not buy you

It is a liveness and timing gate, not a cost function. Be clear-eyed about this, because the distinction decides whether the design is doing what you think.

The parts that hold up are the un-spoofable ones. The server signs each challenge, allows it once, and measures the gap between issuing it and receiving a solution on its own clock. A solution returned in under 1.5 seconds is scored as too fast regardless of what the client claims, so a visitor cannot arrive with a pre-computed answer, and each token costs an attacker a real 1.5 seconds of wall clock per identity.

What the compute cost does not do is price an attacker out. Treat the hash as a gate, not a toll, and do not lean on it as a rate limiter — that job belongs to the timing floor above, and to IP and site-key limits.

This is also why SHA-256 having GPUs and ASICs behind it is not the weakness it appears to be, and why swapping in a memory-hard function (Argon2id, RandomX) would not help. Measured in a real browser, a difficulty-1 Argon2id search at 64MiB costs a throttled low-end phone 6.46s against 0.58s for what ships today, plus a 64MB allocation and 28KB of WASM. That is a large, regressive cost to honest mobile users for no gain in the constraint that actually binds. Numbers in bench/POW-PRIMITIVE.md.

If you want proof of work to genuinely raise an attacker's cost, the lever is the server-measured elapsed time, not the hash. That is what the adaptive cost keys on.

Each challenge carries a minAgeMs: how long it must be held before a solution is accepted without penalty. A visitor who has done nothing wrong gets the same 1500ms floor the server has always applied. A source that has recently produced strong bot verdicts gets more, up to 15 seconds — which takes it from roughly 40 tokens a minute to four, on hardware no amount of money can speed up.

Difficulty barely moves, and now caps at 5 rather than 6. Difficulty 6 costs a native solver about a millisecond and a budget Android phone about sixteen seconds; escalating there taxes the slowest legitimate devices and constrains nobody. Being on a datacenter address no longer raises difficulty at all — a real person on a corporate VPN or iCloud Private Relay was paying several seconds of blocked hashing for a shared IP — and raises the time floor instead, which a hosted scraper feels as reduced throughput and a person filling in a form does not feel at all.

The client is told minAgeMs and waits it out, so for an ordinary visitor the cost is a short delay rather than a worse score. That distinction matters most for anyone sharing an egress address with whatever earned the delay. A client that submits early anyway is scored, but only as contributory evidence — an older cached client does not know to wait, and should not be treated as automation for it.

Suspicion is held per (site key, address) for 15 minutes, records only verdicts at or above 0.8, and stores nothing but timestamps. It is not cross-session correlation and should not grow into it.

Behavioral Biometrics

  • Mouse trajectory, velocity, and acceleration curves
  • Micro-tremor detection (humans have natural hand shake at 3-25Hz)
  • Click precision, approach directness, pre-click exploration, overshoot corrections
  • Input-event forensics — coalesced pointer-event batches (real mice coalesce several hardware samples per frame; CDP-injected moves don't), movementX/Y vs. position-delta coherence, and teleport clicks (a click dispatched at coordinates with no approach trajectory)
  • Think-time cadence — the agent act → screenshot → inference → act loop leaves bursts of activity separated by multi-second perfect silence
  • Mobile-native — touch kinematics (multi-touch, force/radius variance) and passive device-sensor entropy, exempting genuine touch and keyboard-only users
  • Scroll morphology — a wheel or trackpad advances the page tens of pixels per event; scrollIntoView() covers the whole page in one. Measured separation: 109px vs. 704px per event. Stands down for keyboard users, since PageDown legitimately jumps a viewport

Environmental & Automation

  • WebDriver / automation framework detection (Selenium, Puppeteer, Playwright, PhantomJS, Nightmare, Watir)
  • CDP detection — legacy ChromeDriver/Selenium globals plus a Runtime/DevTools console-attach probe that catches any attached protocol client, even when JS globals are scrubbed
  • Headless browser indicators, plugin/feature checks, UA ↔ platform consistency
  • Canvas / WebGL / Audio fingerprinting (session-scoped only)
  • TLS fingerprinting — JA3 (client-supplied) and JA4. JA4 is computed natively from the ClientHello when the Go server terminates TLS itself (FCAPTCHA_TLS_CERT/FCAPTCHA_TLS_KEY), which no client can assert; behind a terminating proxy it falls back to a trusted header. Only JA4-TLS is implemented — the rest of the JA4 family is licensed non-commercially and cannot ship here

Temporal Signals

  • Proof of Work timing (reveals API round-trip latency)
  • Interaction timing patterns and event-sequence analysis
  • Page-load-to-interaction timing

Form Interaction

  • Programmatic form.submit() and programmatic-click detection
  • Programmatic fill — content that appears with zero keystrokes and zero pastes (Playwright fill() / element.value=)
  • Time from page load to submission; events-before-submit (no events = bot)
  • Textarea keystroke analysis — paste ratio, typing speed, rhythm/cadence, keydown/keyup ratio
  • Typing cadence floors — inter-key interval and key hold time both below the human range. Measured on real hardware: humans 226.9ms median with 4549 variance and 82ms holds; a scripted agent 7.9ms, variance 8, 7.8ms holds. Gated behind a ten-keystroke minimum and a no-paste requirement, because a paste is two keydowns a millisecond apart and people paste constantly
  • Paste/platform contradiction — Ctrl+V from a client claiming macOS, or Meta+V from one claiming Windows, with the paste actually landing. No threshold and no calibration: the client has disagreed with itself. Requiring a completed paste keeps it off the Windows switcher fat-fingering Ctrl+V on a new Mac, where that combination does nothing

Declared Agents & Reputation

  • Self-identifying AI-agent user-agents (ClaudeBot, Claude-User, GPTBot, ChatGPT-User, OAI-SearchBot, PerplexityBot, Google-Extended, CCBot, Bytespider, meta-externalagent, Amazonbot, cohere-ai, …)
  • Web Bot Auth (RFC 9421 HTTP Message Signatures) — cryptographic signature verification against the agent's published key directory (verified / forged / unverified), Go + Node
  • Datacenter / VPN / proxy IP reputation and reverse-DNS heuristics (with a 2s lookup timeout so request handlers never block)

AI Agent Detection

FCaptcha targets three classes of modern AI agent, each with a different tell.

1. Vision agents (screenshot → API → click)

A vision agent takes a screenshot, sends it to a vision model (GPT-4V, Claude, etc.) for click coordinates, and executes the click. That pattern is exploitable:

Signal Human Vision agent
Mouse movement Natural curves, micro-tremor Smooth/linear paths
Pre-click behavior Exploration, hesitation Direct path to target
Click trajectory Approach path to the target Teleport — click with no preceding movement
Coordinate precision Slight variance Pixel-perfect
PoW timing Consistent with local execution Delayed by API round-trip

2. Computer-use / CDP agents (driving a real browser)

Agents like Claude in Chrome (via chrome.debugger) or Operator-style tools (via Playwright/CDP) drive a real browser, so their input events report isTrusted: true and slip past global-based checks. Their shape still betrays them:

Signal Human CDP-driven agent
Coalesced pointer events Multiple hardware samples per frame Single-entry batches (synthetic)
movementX/Y vs. position Coherent Incoherent / zero while position changes
Activity cadence Continuous, noisy idle Bursts separated by multi-second silence (think time)
Field entry Per-character keystrokes Programmatic fill — value set, no keys/pastes
Protocol surface None DevTools/Runtime console consumer attached

3. Declared agents (the agentic web)

Many legitimate agents and crawlers identify themselves — by user-agent (ClaudeBot, GPTBot, PerplexityBot, …) or by cryptographically signing requests with Web Bot Auth (RFC 9421). FCaptcha verifies those signatures against the agent's published key directory:

  • verified → a distinct declared_ai category (high confidence, low default severity) — a trustworthy identity your app can allow by policy.
  • forged (signature fails cryptographic verification) → a contributory bot signal — affirmative evidence of a spoofed identity claim.
  • unverified (unreachable/blocked directory, timeout) → fails open to a presence-only signal; a directory it couldn't fetch is never treated as proof of spoofing.

So declared and verified agents get policy handling instead of being treated as attackers, and a bot can't buy leniency just by attaching three unsigned headers. (Go + Node verify cryptographically; the Python server still identifies by header presence.)

API Reference

GET /api/pow/challenge

Get a Proof of Work challenge. Called automatically by the client on page load.

// Request: GET /api/pow/challenge?siteKey=your-site-key

// Response
{
  "challengeId": "abc123...",
  "prefix": "abc123:1703356800000:4",
  "difficulty": 4,
  "minAgeMs": 1500,
  "expiresAt": 1703357100000,
  "nonce": "f1e2d3...",
  "sig": "def456..."
}

The nonce is generated per-challenge by the server; the client echoes it back in signals.meta.challengeNonce and the server verifies it, preventing challenge replay.

minAgeMs is how long the client should hold the solved challenge before submitting. The bundled client does this for you. Both fields are covered by sig, so neither can be talked down on the way back.

Cost scales with what the requesting source has recently been caught doing:

source difficulty minAgeMs
clean, or unknown 4 1500
1–2 recent strong bot verdicts 4 4000
3–5 5 8000
6+ 5 15000

Datacenter addresses, high request rates and exceeded rate limits raise the time floor only — never the difficulty. See what proof of work does and does not buy you for why.

POST /api/verify

Verify a checkbox CAPTCHA submission.

// Request
{
  "siteKey": "your-site-key",
  "signals": { /* collected signals */ },
  "signalsJson": "{...}",            // canonical serialization, hashed for signal commitment
  "powSolution": {
    "challengeId": "abc123...",
    "nonce": 68455,
    "hash": "0000abc...",
    "signalsHash": "9f86d0..."       // SHA-256 of signalsJson, bound into the PoW input
  },
  "powTiming": { "duration": 230, "iterations": 41000, "difficulty": 4 }
}

// Response
{
  "success": true,
  "score": 0.15,
  "token": "...",
  "recommendation": "allow"
}

signalsJson is sent alongside signals for deterministic hashing across languages; the server recomputes SHA-256(signalsJson) and checks it matches powSolution.signalsHash, so signals can't be swapped after the proof of work is solved. powTiming is sent separately (not inside the committed signals) to avoid a chicken-and-egg with PoW timing.

POST /api/score

Get a score for invisible mode.

// Request
{
  "siteKey": "your-site-key",
  "signals": { /* collected signals */ },
  "action": "login",
  "powSolution": {
    "challengeId": "abc123...",
    "nonce": 68455,
    "hash": "0000abc..."
  }
}

// Response
{
  "success": true,
  "score": 0.12,
  "token": "...",
  "action": "login"
}

POST /api/token/verify

Verify a previously issued token (server-side).

// Request
{
  "token": "...",
  "secret": "your-secret"
}

// Response
{
  "valid": true,
  "site_key": "your-site-key",
  "score": 0.15,
  "timestamp": 1703356800
}

Configuration

Environment Variables

Variable Description Default
FCAPTCHA_SECRET Secret key for token signing (required)
PORT Server port 3000
REDIS_URL Redis URL for distributed state (in-memory)
TRUSTED_PROXIES Comma-separated CIDRs/IPs of peers allowed to set X-Forwarded-For, X-Real-IP and the TLS-fingerprint headers. * trusts every peer, none trusts none. See Trusted proxies loopback + private ranges
FCAPTCHA_SITE_KEYS Comma-separated allowlist of accepted site keys. Unset accepts any key (zero-config self-hosting); unlisted keys are folded into a shared overflow bucket rather than allocating their own rate-limit/fingerprint state (any)
FCAPTCHA_MAX_SITE_KEYS_PER_IP Distinct site keys one IP may allocate state for before the excess is folded into the overflow bucket. The cap itself is unconditional 8
TRUSTED_JA4_HEADERS Comma-separated reverse-proxy header names carrying a JA4 TLS fingerprint (e.g. set by nginx/Cloudflare). Only these names are read, and only from a peer in TRUSTED_PROXIES (none)
FCAPTCHA_TLS_CERT / FCAPTCHA_TLS_KEY Serve HTTPS directly (Go server). Terminating TLS here is what makes native JA4 possible — the fingerprint is computed from the ClientHello rather than taken on trust from a proxy. Behind Railway/Cloudflare/nginx, leave unset and use TRUSTED_JA4_HEADERS (none, plain HTTP)
FCAPTCHA_CLIENT_PATH Explicit path to client/fcaptcha.js for same-origin widget serving (auto-probed)
FCAPTCHA_SERVE_CLIENT (Python) Serve the widget at /fcaptcha.js; set false to host the client on a separate CDN true
FCAPTCHA_PPROF (Go) Enable the pprof debug server (1/true/yes/on) off
FCAPTCHA_PPROF_ADDR (Go) Listen address for pprof when enabled — keep it loopback-only 127.0.0.1:3001
FCAPTCHA_LOG_VERDICTS Log one privacy-safe JSON line per /api/verify and /api/score (score, recommendation, category scores, and per-hit category/score/confidence). Omits IP, user agent, raw signals, and free-text detection reasons. For observability/tuning (1/true/yes/on) off
FCAPTCHA_LOG_VERDICTS_INCLUDE_RAW Also include the free-text detection reason in verdict logs. Reasons can contain visitor-derived data (reverse-DNS hostnames, UA/header fragments, form field ids) — only enable in trusted debugging contexts with no privacy obligations. Requires FCAPTCHA_LOG_VERDICTS off

Trusted proxies

Every IP-derived check — datacenter ranges, Tor/VPN, rate limiting, token IP binding, PoW difficulty — is only as good as the address the server is handed. X-Forwarded-For and X-Real-IP are set by whoever opened the connection, so FCaptcha reads them only when the peer is in TRUSTED_PROXIES. Any other caller is attributed to its socket address, and a forged header is ignored. The same gate covers X-JA3-Hash and any header named in TRUSTED_JA4_HEADERS.

The default — loopback plus the private and link-local ranges — covers nginx on 127.0.0.1, a sidecar proxy, and an in-cluster ingress with no configuration, while a server exposed directly to the internet sees public peers and therefore ignores their headers.

Set it explicitly when your proxy reaches FCaptcha from an address outside those ranges — a Cloudflare Tunnel, a load balancer in another VPC, or a PaaS edge (Railway's is in the RFC 6598 CGNAT block):

TRUSTED_PROXIES=100.64.0.0/10 ./fcaptcha-server   # Railway

If you get this wrong in the unsafe direction nothing breaks visibly, so FCaptcha logs it: a peer that sends forwarding headers but is not trusted produces a one-time warning naming the address to add. Check your logs after deploying — an unlisted proxy means every visitor is being attributed to it, which quietly ruins rate limiting and IP reputation.

* trusts every peer and restores the spoofing bypass — use it only when an edge you control always overwrites the headers. none (or an empty value) always uses the socket address. Each server logs the resolved set on startup. Full details in INSTALLATION.md.

Score Thresholds

Score Recommendation
< 0.3 Allow - likely human
0.3 - 0.6 Challenge - uncertain
> 0.6 Block - likely bot

How the score is composed

Within a category, detections combine as noisy-OR — each is treated as independent evidence of strength score x confidence, and the category is the probability that at least one is right. Categories then contribute by weight.

This replaced a confidence-weighted mean, which had a property nobody intended: corroborating evidence lowered the verdict. A browser reporting navigator.webdriver = true and nothing else scored 0.95 in the headless category; the same browser also caught with no plugins, a software renderer and five more automation tells scored 0.686, because each additional signal was individually weaker than the first and pulled the average down. Evidence now accumulates, and an isolated low-confidence hit counts for less than it used to rather than setting a whole category.

Self-declared automation sets a floor. A weighted sum across eleven categories means no single fact can consume much of the budget, so a blatant local agent used to land near 0.5 — "challenge", not "block". Detections marked dispositive now floor the score at 0.9. The bar for that mark is that a browser cannot produce the signal without being automated: navigator.webdriver (W3C-specified, its whole purpose is to announce automation) and ChromeDriver/Puppeteer injected globals. The DevTools console-attach probe is deliberately excluded — the benchmark's human panel proves it fires on a developer with DevTools open.

This does not catch a stealth agent that patches the flag before the page sees it, and is not meant to. It closes the case where an agent is not trying to hide.

Project Structure

fcaptcha/
├── client/
│   └── fcaptcha.js          # Client-side widget, signal collection, parallel PoW Web Workers
├── server-go/
│   ├── main.go              # Go HTTP server + same-origin widget serving
│   ├── scoring.go           # Scoring engine, PoW verification, behavioral/vision/CDP detectors
│   ├── detection.go         # IP reputation, headers, declared-AI, JA3/JA4, form analysis
│   ├── clientip.go          # Trusted-proxy client IP resolution (TRUSTED_PROXIES)
│   ├── sitekeys.go          # Bounds on state a client-supplied siteKey can allocate
│   ├── inputforensics.go    # Typing cadence/modality, scroll morphology, platform coherence
│   ├── ja4.go               # Native JA4-TLS from the ClientHello (Go 1.24+, stdlib only)
│   ├── scoring_test.go      # Go unit tests
│   ├── clientip_test.go     # Trusted-proxy unit tests
│   └── go.mod
├── server-python/
│   ├── server.py            # Python/FastAPI server + PoW + detectors
│   ├── detection.py         # IP reputation, headers, declared-AI, JA3/JA4, form analysis
│   ├── clientip.py          # Trusted-proxy client IP resolution (TRUSTED_PROXIES)
│   ├── sitekeys.py          # Bounds on state a client-supplied siteKey can allocate
│   ├── inputforensics.py    # Typing cadence/modality, scroll morphology, platform coherence
│   ├── test_clientip.py     # Trusted-proxy unit tests
│   └── requirements.txt
├── server-node/
│   ├── server.js            # Node.js/Express server + PoW + detectors
│   ├── detection.js         # IP reputation, headers, declared-AI, JA3/JA4, form analysis
│   ├── clientip.js          # Trusted-proxy client IP resolution (TRUSTED_PROXIES)
│   ├── limits.js            # LRU-bounded stores + siteKey cardinality guard
│   ├── inputforensics.js    # Typing cadence/modality, scroll morphology, platform coherence
│   ├── clientip.test.js     # Trusted-proxy unit tests
│   └── package.json
├── bench/
│   ├── capture/             # Drives real Chromium, intercepts the /api/verify body
│   ├── corpus/captured/     # Committed real browser traces (14 human + 8 agent personas)
│   ├── lib/                 # Corpus schema, browser-paced PoW client, metrics, reporting
│   ├── run-bench.js         # Replay + per-signal FP budgets + CI gate
│   └── README.md            # What the numbers do and do not establish — read first
├── test/
│   └── test-detection.js    # End-to-end detection test suite (runs against a live server)
├── demo/
│   └── index.html           # Interactive demo page
├── docker/
│   ├── Dockerfile           # Multi-stage build (Go binary + client + demo)
│   └── docker-compose.yml   # Docker compose with Redis
├── .github/workflows/
│   ├── bench.yml            # Unit tests + E2E + gated detection benchmark
│   ├── docker-publish.yml   # GHCR publish on release
│   └── npm-publish.yml      # npm publish on release
├── .dockerignore
├── ARCHITECTURE.md          # Technical architecture documentation
└── README.md

All three servers implement the same detection engine and must stay in sync. Each has unit tests that run in CI, and test/test-detection.js exercises the full pipeline against a running server.

Development

# Run Go server
cd server-go && go run .

# Run Python server
cd server-python && python server.py

# Run Node server
cd server-node && node server.js

# Open demo
open demo/index.html

Running Tests

Unit tests, no server required. All three run in CI on every pull request.

cd server-go     && go test -race ./...              # Go
cd server-node   && npm test                         # Node
cd server-python && python -m unittest discover -p "test_*.py"   # Python

The Python tests are plain functions collected by a decorator rather than TestCase methods, so testkit.py bridges them into unittest — run a single file directly (python test_sitekeys.py) for readable per-test output. Discovery and direct execution both report the same set; if the discovered count drops, a file has stopped being discoverable.

The measurement harness has its own tests:

cd bench && npm test

End-to-end detection suite (runs against a live server):

# Start a server first (any language)
cd server-node && node server.js &

# Run the suite
node test/test-detection.js

Coverage spans bot user-agents, headless/CDP detection, declared AI agents, datacenter/IP reputation, HTTP header and TLS (JA3/JA4) analysis, browser consistency, behavioral and input-event-forensics signals, vision/agent detection, form interaction (paste + programmatic fill), accessibility false positives, proof of work, token verification, and invisible-mode scoring.

The three servers do not all pass the full suite. It is written against the Node implementation; Go and Python have documented divergences (Python maps datacenter onto BOT and has no datacenter category; neither implements the advanced headless fingerprint checks). Baseline before assuming a regression.

Detection benchmark

bench/ measures what the detection layer actually does to real users. It drives a real Chromium through real input events, intercepts the /api/verify body the widget produces, and replays that labeled corpus against a running server — reporting a false-positive rate per persona, a true-positive rate per agent class, and a per-signal FP budget that gates CI.

cd bench && npm install && npm run install-browsers
node capture/record.js          # one-time; the corpus is committed
node run-bench.js               # human FPR, agent TPR, per-signal budgets
node run-bench.js --gate        # non-zero exit when a signal is over budget

Read bench/README.md before quoting any number from it. A 0% false-positive rate there means "no sample in this corpus crossed the threshold" — not "0% of real users would". Fourteen scripted personas are a structural stand-in for fourteen kinds of user, not a population sample, and the panel's environment is partly reconstructed because an automated browser announces itself. The harness documents both limits rather than burying them.

What it is good for is catching regressions and false positives that reasoning alone misses. Its first runs found seven, including mouse-trajectory checks firing on users who have no mouse, a touch exemption that a plain tap could not satisfy, and forwarding headers scored as suspicious on every visitor behind a reverse proxy.

Contributing

Contributions welcome! Please read ARCHITECTURE.md first. AI-agent detection is built out in phases — declared agents, input-event forensics, Web Bot Auth signature verification, the measurement harness, and typing/scroll/platform-coherence forensics have shipped; hosted-agent environment composites, accessibility-tree honeypots, and cross-session correlation are still open.

The most useful contribution right now is captured traces from real browsers driven by real people. The benchmark corpus is currently scripted personas from a single machine, which bounds what any false-positive number from it can mean. bench/corpus/captured/ takes new samples directly — see bench/README.md.

Areas that could use help:

  • Web Bot Auth verification for the Python server (Go and Node verify cryptographically against the agent's published key directory; Python still identifies by header presence — no maintained Python library yet)
  • Cross-session / per-fingerprint behavioral correlation (the durable defense against source-patched browsers)
  • Machine learning-based scoring
  • Admin dashboard and analytics
  • WebAssembly-based PoW for better mobile performance
  • Redis-backed distributed state (currently in-memory)

When adding or changing a detector, apply it to all three server implementations (Go, Python, Node) so they stay in sync.

License

MIT License - use freely, contribute back if you can.


Privacy Note: FCaptcha is designed with privacy in mind. No persistent fingerprinting, no cross-site tracking, no PII collection. All fingerprints are session-scoped and used only for bot detection.

About

Open-source, invisible CAPTCHA that detects AI agents, bots, and headless browsers via 40+ behavioral signals, device & TLS fingerprinting, and SHA-256 proof of work. Self-hosted and privacy-first.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages