This guide covers installing and deploying FCaptcha for development and production environments.
- Requirements
- Quick Start (Development)
- Server Installation
- Docker Deployment
- Production Setup
- Configuration Reference
- Upgrading to 1.23.0
- Upgrading to 1.22.0
- Verification
- Troubleshooting
| Language | Version | Notes |
|---|---|---|
| Node.js | 18+ | Recommended: 20 LTS |
| Python | 3.10+ | Recommended: 3.12 |
| Go | 1.24+ | Required for native JA4 support |
- Docker - For containerized deployment
- Nginx/Caddy - For reverse proxy and TLS termination
Get FCaptcha running in under 2 minutes:
# Clone the repository
git clone https://github.com/yourusername/fcaptcha.git
cd fcaptcha
# Start the Node.js server (easiest)
cd server-node
npm install
FCAPTCHA_SECRET=local-development-secret node server.js
# Server is now running at http://localhost:3000Open demo/index.html in your browser to test.
FCaptcha ships in two halves: an HTTP API (server-node, server-python, server-go) and a browser widget (client/fcaptcha.js).
By default, server-node serves the widget at /fcaptcha.js from the same origin as the API. Integrators that expose a single serverUrl to their clients (and load the widget from <serverUrl>/fcaptcha.js) work out of the box. Most consumers want this.
If you'd rather host the widget on a CDN or edge cache and only run the API from the FCaptcha server, set FCAPTCHA_SERVE_CLIENT=false and serve client/fcaptcha.js yourself from wherever fits your infrastructure. Point your widget loader at that URL.
If you've copied server-node/ to a location where the sibling client/ directory isn't present, set FCAPTCHA_CLIENT_PATH=/absolute/path/to/fcaptcha.js to override the default lookup.
The Go server (server-go) already serves /fcaptcha.js via its embedded static directory. The Python server (server-python) does not yet — track issue #4 for parity.
Step 1: Install dependencies
cd server-node
npm installStep 2: Configure environment
# Create .env file (optional)
echo "FCAPTCHA_SECRET=your-secret-key-here" > .env
echo "PORT=3000" >> .envOr set environment variables directly:
export FCAPTCHA_SECRET=your-secret-key-here
export PORT=3000Step 3: Start the server
# Development
node server.js
# Production (with PM2)
npm install -g pm2
pm2 start server.js --name fcaptcha
pm2 saveStep 4: Verify
curl http://localhost:3000/health
# {"status":"ok"}Step 1: Create virtual environment (recommended)
cd server-python
python3 -m venv venv
source venv/bin/activate # Linux/Mac
# or: venv\Scripts\activate # WindowsStep 2: Install dependencies
pip install -r requirements.txtStep 3: Configure environment
export FCAPTCHA_SECRET=your-secret-key-here
export PORT=3000Step 4: Start the server
# Development
python server.py
# Or with uvicorn directly — note --no-proxy-headers
uvicorn server:app --host 0.0.0.0 --port 3000 --no-proxy-headers
# Production (with gunicorn)
pip install gunicorn
gunicorn server:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:3000 \
--forwarded-allow-ips=""Why
--no-proxy-headers. uvicorn does its ownX-Forwarded-Forresolution by default, rewritingrequest.clientfrom the header whenever the real peer is loopback. FCaptcha then sees the claimed address where it expects the socket peer, and its own trusted-proxy logic ends up checking the visitor's IP against the proxy allowlist. Behind a same-host reverse proxy that means every request is logged as coming from an untrusted peer and picks up a spurious detection. LetTRUSTED_PROXIESbe the only thing that decides which peers may speak for a client.python server.pysets this for you.
Step 5: Verify
curl http://localhost:3000/health
# {"status":"ok"}
# The browser widget is served from the same origin by default:
curl -I http://localhost:3000/fcaptcha.js
# HTTP/1.1 200 OKBy default, server-python serves client/fcaptcha.js at /fcaptcha.js so integrators that expose a single serverUrl to their clients (and load the widget from <serverUrl>/fcaptcha.js) work out of the box. Set FCAPTCHA_SERVE_CLIENT=false to opt out (e.g. when hosting the widget on a CDN), or FCAPTCHA_CLIENT_PATH=/abs/path/to/fcaptcha.js to override the default lookup when server-python/ is deployed without the sibling client/ directory.
Step 1: Build the binary
cd server-go
go build -o fcaptcha-server .Step 2: Configure environment
export FCAPTCHA_SECRET=your-secret-key-here
export PORT=3000Step 3: Run the server
./fcaptcha-serverStep 4: Verify
curl http://localhost:3000/health
# {"status":"ok"}Node.js
cd server-node
docker build -t fcaptcha-node .
docker run -d \
--name fcaptcha \
-p 3000:3000 \
-e FCAPTCHA_SECRET=your-secret-key-here \
fcaptcha-nodePython
cd server-python
docker build -t fcaptcha-python .
docker run -d \
--name fcaptcha \
-p 3000:3000 \
-e FCAPTCHA_SECRET=your-secret-key-here \
fcaptcha-pythonGo
cd server-go
docker build -t fcaptcha-go .
docker run -d \
--name fcaptcha \
-p 3000:3000 \
-e FCAPTCHA_SECRET=your-secret-key-here \
fcaptcha-goCreate docker-compose.yml:
version: '3.8'
services:
fcaptcha:
build: ./server-node # or server-python, server-go
ports:
- "3000:3000"
environment:
- FCAPTCHA_SECRET=your-secret-key-here
restart: unless-stoppedFCaptcha state is process-local by default. In the Go server, REDIS_URL now
shares PoW challenges, token replay protection, Siteverify idempotency, rate
limits, suspicion, fingerprint cardinality, and site-key rotation guards.
Challenge and token claims are atomic. Go may run multiple replicas when Redis
is configured. Node and Python share the same security-state classes and may
also run multiple replicas with Redis.
Run:
docker-compose up -d# Generate a 32-character random secret
openssl rand -hex 32
# Example output: a1b2c3d4e5f6...
export FCAPTCHA_SECRET=a1b2c3d4e5f6...# /etc/nginx/sites-available/fcaptcha
server {
listen 80;
server_name captcha.yourdomain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name captcha.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/captcha.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/captcha.yourdomain.com/privkey.pem;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Cache static assets
location /fcaptcha.js {
proxy_pass http://127.0.0.1:3000;
proxy_cache_valid 200 1h;
add_header Cache-Control "public, max-age=3600";
}
}Enable the site:
sudo ln -s /etc/nginx/sites-available/fcaptcha /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginxThis layout needs no TRUSTED_PROXIES setting: nginx reaches FCaptcha over
127.0.0.1, which is trusted by default, so the X-Real-IP it sets is honoured.
Make sure FCaptcha itself only listens on loopback — if port 3000 is also
reachable from the internet, callers bypass nginx and set that header
themselves. See Trusted proxies.
# /etc/caddy/Caddyfile
captcha.yourdomain.com {
reverse_proxy localhost:3000
header {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
}
}Create /etc/systemd/system/fcaptcha.service:
[Unit]
Description=FCaptcha Server
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/fcaptcha/server-node
Environment=NODE_ENV=production
Environment=FCAPTCHA_SECRET=your-secret-key-here
Environment=PORT=3000
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.targetEnable and start:
sudo systemctl daemon-reload
sudo systemctl enable fcaptcha
sudo systemctl start fcaptcha
sudo systemctl status fcaptchaFor high availability, run multiple instances behind a load balancer:
upstream fcaptcha_backend {
least_conn;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
server 127.0.0.1:3003;
}
server {
listen 443 ssl http2;
server_name captcha.yourdomain.com;
location / {
proxy_pass http://fcaptcha_backend;
# ... other proxy settings
}
}Important: Multiple Go instances require REDIS_URL; without it, all state
is process-local. Node and Python also support multiple instances with Redis.
| Variable | Required | Default | Description |
|---|---|---|---|
FCAPTCHA_SECRET |
Yes | - | Secret key for signing tokens (min 16 chars) |
FCAPTCHA_INSECURE_DEV_MODE |
No | off | Explicitly use the public development signing key for local-only development. Never expose a server with this enabled |
REDIS_URL |
No | - | Redis URL for shared security state. Go, Node, and Python support multiple replicas. Configuration and runtime failures are fail-closed |
FCAPTCHA_VERIFY_SECRET |
No | FCAPTCHA_SECRET |
Credential your backend sends as secret when verifying a token. Split it from the signing key so a leaked verify credential cannot also mint tokens |
FCAPTCHA_LEGACY_UNAUTH_VERIFY |
No | off | Restore the pre-1.22.0 behaviour where token verification accepted any caller. Migration cover for one release — see Upgrading to 1.22.0 |
FCAPTCHA_ALLOWED_HOSTNAMES |
No | (any) | Comma-separated hostnames permitted to mint tokens, matched against the request Origin (then Referer) |
PORT |
No | 3000 | Server port |
NODE_ENV |
No | development | Set to production for Node.js |
TRUSTED_PROXIES |
No | loopback + private ranges | Peers allowed to set X-Forwarded-For / X-Real-IP / TLS-fingerprint headers. See 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.
TRUSTED_PROXIES takes a comma-separated list of CIDRs and bare IPs:
| Value | Meaning |
|---|---|
| unset | Loopback plus the private and link-local ranges (127.0.0.0/8, ::1/128, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, fe80::/10, fc00::/7) |
10.0.0.0/8,203.0.113.7 |
Only these peers |
* |
Every peer. Correct only when an edge you control always overwrites the headers — otherwise this restores the spoofing bypass |
none (or empty) |
No peer; always use the socket address |
The default covers the usual setups — nginx on 127.0.0.1, a sidecar proxy, an
in-cluster ingress, Railway, Fly — with no configuration. Set it explicitly when
your proxy reaches FCaptcha from a public address (for example a Cloudflare
Tunnel or a load balancer in another VPC), and list only that proxy's ranges.
The server logs the resolved set on startup:
Trusted proxies: 127.0.0.0/8, ::1/128, 10.0.0.0/8, ...
If FCaptcha is reachable directly as well as through your proxy, restrict it at the firewall — a trusted-proxy list cannot help if an attacker can connect from inside a trusted range.
The failure to watch for is not a bypass — it is the quiet opposite. If your
reverse proxy's address is not in the trust set, FCaptcha ignores the
X-Real-IP it sets and attributes every visitor to the proxy. Rate limiting
then throttles all traffic as one client and IP reputation becomes meaningless.
FCaptcha logs this the first time it happens, naming the address to add:
warning: ignoring forwarding headers from untrusted peer 100.64.3.7. If that is
your reverse proxy, add it to TRUSTED_PROXIES — until then every visitor is
attributed to 100.64.3.7. If not, a client is spoofing them and they are
correctly ignored.
Check the logs after deploying. If that warning names your proxy, add its range. If it names random internet addresses, that is spoofing being correctly blocked.
Docker. Three cases, measured:
| Setup | Peer the container sees | Result |
|---|---|---|
| Proxy container on the same compose network | its bridge address, e.g. 172.17.0.4 |
inside 172.16.0.0/12, trusted — works with no config |
| Published port on Docker Desktop (macOS/Windows) | the VM gateway, 192.168.65.1 |
inside 192.168.0.0/16, so trusted |
| Published port on Linux | Docker's DNAT normally preserves the caller's real address | public callers are untrusted, as intended |
The middle row is the one to watch. Docker Desktop NATs published-port traffic through its VM gateway, so every caller arrives from a private, trusted address and can forge forwarding headers. That is harmless for local development, but if you ever expose such a port to the internet, turn the default off:
docker run -p 3000:3000 -e TRUSTED_PROXIES=none ghcr.io/webdecoy/fcaptchaDo not infer your own case from this table — confirm it. Start the container, send a request, and read the log line it prints:
docker logs <container> | grep -E 'trusted proxies|from 'The request log shows the peer address the server actually saw. If that address
is inside the trust set and is not your reverse proxy, forwarding headers are
forgeable and you want TRUSTED_PROXIES=none.
PaaS (Railway, Fly, Render, Heroku). These route through an edge proxy whose
address is often outside RFC 1918. Railway's, for example, is in the RFC 6598
carrier-grade NAT block — observed peers are 100.64.0.x. The defaults will not
cover it, so set it explicitly:
TRUSTED_PROXIES=100.64.0.0/10 # RailwayRailway's own guidance says only "the 100.0.0.0/8 range", which is looser than
what it actually uses and would trust ~64 million publicly-routable addresses.
100.64.0.0/10 is the real CGNAT block and is the safer setting; if the warning
above ever names a 100.x peer outside it, widen to 100.0.0.0/8.
This is safe on a PaaS precisely because the container is not directly reachable: the platform's proxy is the only possible peer. Confirm against the startup log and the warning above rather than assuming.
| Difficulty | Approx. Time | When Used |
|---|---|---|
| 4 | 100-500ms | Default for all requests |
| 5 | 500ms-3s | Datacenter IPs |
| 6 | 2-10s | Rate-limited IPs |
| Score Range | Recommendation | Typical Action |
|---|---|---|
| 0.0 - 0.3 | Allow | Proceed normally |
| 0.3 - 0.5 | Allow | Log for monitoring |
| 0.5 - 0.7 | Challenge | Show additional verification |
| 0.7 - 1.0 | Block | Reject request |
A proof of work is now required for a token. /api/verify and /api/score
withhold the token when the request carries no valid PoW solution, instead of
merely scoring it.
If you use the bundled widget, nothing changes — it already solves a challenge on every path and aborts rather than submit without one. Verified against the measurement harness: human false-positive rate stays 0.00% and the human median score is unchanged at 0.097.
If you call /api/verify directly from your own client, you must now complete
the handshake: GET /api/pow/challenge, echo the returned nonce in
signals.meta.challengeNonce, commit the signals into the PoW input, and send
the solution as powSolution. A refusal reports "reason": "pow_not_satisfied".
Why: the final score is a weighted sum, so the bot category could contribute at
most its 0.13 weight against a 0.5 threshold. Every PoW failure firing at once
reached 0.1298 — so a bare curl with no solution and no signals was issued a
valid token. There is no configuration to restore the old behaviour; it was a
bypass, not a feature.
Two changes need action before you deploy.
1. Token verification now requires the secret.
POST /api/token/verify used to accept the secret parameter and ignore it, so
any caller who could reach the endpoint could spend a token. It is now checked.
If your backend already sends secret (as the README has always shown), nothing
changes. If it does not, either add it:
POST /api/token/verify
- {"token": "..."}
+ {"token": "...", "secret": "<FCAPTCHA_SECRET>"}or set FCAPTCHA_LEGACY_UNAUTH_VERIFY=true to keep the old behaviour for one
release while you update. A wrong or missing secret answers 401.
The native endpoint also accepts an optional remoteip containing the original
visitor address. Omit it to skip IP binding. FCaptcha does not use the backend
caller's socket address because that is not the visitor who received the token.
2. The token format is now identical across the three servers.
Go emitted padded base64url, Node unpadded, and Python signed a payload with different JSON spacing — so no two implementations could verify each other's tokens. This went unnoticed because each server only ever verified its own. All three now emit unpadded base64url over a compact, sorted-key payload, and all three accept the old encodings, so a rolling deploy is safe and tokens in flight keep working.
This only mattered if you ran a mixed fleet (say Go and Node behind one load balancer) or migrated between implementations; in that setup verification was failing intermittently before and works now.
# Make sure server is running first
cd /path/to/fcaptcha
node test/test-detection.js
# Expected output:
# FCaptcha Detection Test Suite
# Testing against: http://localhost:3000
# ...
# Passed: 50
# Failed: 0
# All tests passed!# Health check
curl http://localhost:3000/health
# Get PoW challenge
curl "http://localhost:3000/api/pow/challenge?siteKey=test"
# Verify (will fail without valid signals, but confirms endpoint works)
curl -X POST http://localhost:3000/api/verify \
-H "Content-Type: application/json" \
-d '{"siteKey":"test","signals":{}}'- Open
demo/index.htmlin a browser - Click the checkbox
- Should show green checkmark when verified
Port already in use:
# Find what's using the port
lsof -i :3000
# Kill it or use a different port
export PORT=3001Missing dependencies:
# Node.js
rm -rf node_modules && npm install
# Python
pip install -r requirements.txt --force-reinstall
# Go
go mod tidyMake sure your frontend is calling the correct server URL:
FCaptcha.serverUrl = 'http://localhost:3000'; // Development
FCaptcha.serverUrl = 'https://captcha.yourdomain.com'; // Production- Check that
FCAPTCHA_SECRETis the same on all servers - Tokens expire after 5 minutes - verify within that window
- Each token can only be verified once
Check the detection details in the response:
curl -X POST http://localhost:3000/api/verify \
-H "Content-Type: application/json" \
-d '{"siteKey":"test","signals":{}}' | jq '.detections'Common causes:
- Missing PoW solution (client not sending it)
- VPN/datacenter IP addresses
- Browser privacy extensions blocking fingerprinting
On slower devices, PoW may take longer. The difficulty auto-scales, but you can adjust thresholds in the server code if needed.
- Read ARCHITECTURE.md for technical details
- Check the README.md for integration examples
- Run
node test/test-detection.jsto verify your setup
- GitHub Issues: github.com/yourusername/fcaptcha/issues
- Documentation: README.md