Self-hosting
Serve the loader from your own origin, or run the whole service yourself: the binary, the store, the ports and the settings.
Two different things
Hosting the loader means serving the script file from your own origin while the checks still run on our service. It suits a strict content security policy, change control that wants to review every byte that runs on the page, or a wish to keep third-party hosts out of the page entirely. Running the service means operating the whole system yourself, including the verify endpoint and the store, so no request leaves your infrastructure. The same software runs our service and a self-hosted installation; self-hosting the service is part of the Enterprise conversation on the pricing page.
Hosting the loader
The loader is one self-contained file. Copy it from us, serve it as application/javascript, and reference it from your page. It carries the address of our API, so no configuration changes; the only differences are that your origin now serves the script and starts the workers, and that updates arrive when you copy a new file rather than automatically.
# Copy the current loader and keep its hash with the copy
curl -sS -o otcaptcha.js https://api.otcaptcha.com/captcha/v1/otcaptcha.js
shasum -a 256 otcaptcha.js<script src="[SCRIPT URL]" defer></script>
<!-- for example /static/otcaptcha.js?v=3; change the query when you copy a new file -->- Workers. The background workers are started from the script's own URL, so serve it from an origin your
worker-srcallows, with the right content type, and do not rewrite or wrap the file: it detects that it is running inside a worker by the absence ofdocument. - Caching. Serve it with a long cache life and change the URL, or its query string, when you copy a new file, so no browser runs a stale copy next to a new one.
- Compression. Serve it compressed. We precompress with brotli and gzip; the file is about 12 KB gzipped and under 11 KB with brotli.
- Version. The loader sends its version with every call, and your reports show which versions your visitors run, so a copy that falls behind is visible. The changelog says what each version changed.
- Integrity. Record the SHA-256 of the file you copied. A
integrityattribute on the script tag works only when the file is served from your origin, and it must be updated with every copy.
Running the service
The service is one statically linked binary, built from source into a container image with nothing else in it, plus a store. It has no other runtime dependency.
| Part | Default | What it serves |
|---|---|---|
| Public listener | :8080 | /captcha/v1/otcaptcha.js, /load, /verify, /puzzle, /funnel, plus /health-check and /ready. Expose this one to browsers. |
| Internal listener | :8081 | /validate, the server check, plus the health routes. Expose this one to your application servers only, and set the address allow-list. |
| Metrics listener | :9800 | /metrics in Prometheus text format. Keep it private. |
| Store | Redis | Spent passes, solved challenges, rate counters and puzzle state. Redis with replicas and Sentinel is supported for failover; an in-memory store exists for a single instance and tests. |
| Records | off | Per-attempt records as files in a directory you name, one line per record, kept for 90 days by default. |
/health-check answers 200 while the process is up and returns no configuration. /ready fails when the store is unreachable, so use it for readiness, not for a load balancer that would otherwise pull every instance at once: the challenge route needs no store and keeps working through a store failure, while the server check answers 503 and your outage policy applies.
Settings
Everything is set through environment variables. On/off settings take on, off, true or false; anything unparseable stops the start, deliberately, so a typo cannot silently leave a protection off.
# Minimal single-instance run for evaluation. Memory store: not shared, so one instance only.
OTCAPTCHA_STORE=memory
OTCAPTCHA_CREDENTIALS_FILE=/etc/otcaptcha/credentials.json
OTCAPTCHA_ALLOWED_ORIGINS=https://www.example.com,https://*.example.net
OTCAPTCHA_TRUSTED_PROXIES=10.0.0.0/8
OTCAPTCHA_REAL_IP_HEADER=X-Forwarded-For
OTCAPTCHA_VALIDATE_ALLOWED_CIDRS=10.0.0.0/8
OTCAPTCHA_EVENTS_DIR=/var/lib/otcaptcha/events| Variable | Default | Meaning |
|---|---|---|
OTCAPTCHA_PUBLIC_ADDR, OTCAPTCHA_INTERNAL_ADDR, OTCAPTCHA_METRICS_ADDR | :8080, :8081, :9800 | The three listeners. |
OTCAPTCHA_PUBLIC_BASE_PATH | /captcha/v1 | Path prefix of the browser routes. |
OTCAPTCHA_CREDENTIALS_FILE | empty | Site keys and secrets, JSON, format below. Required unless an any-key secret is set. |
OTCAPTCHA_ANY_ID_SECRET | empty | At least 32 characters. Lets the service derive a secret for any well-formed site key not in the file, for evaluation; the server check then skips the sign_token for such keys. |
OTCAPTCHA_STORE | redis | redis or memory. |
OTCAPTCHA_REDIS_ADDR, OTCAPTCHA_REDIS_PASSWORD, OTCAPTCHA_REDIS_DB | 127.0.0.1:6379, empty, 0 | The store, when no Sentinels are set. |
OTCAPTCHA_REDIS_SENTINELS, OTCAPTCHA_REDIS_MASTER | empty, otcaptcha | Follow the primary through Sentinel. |
OTCAPTCHA_TOKEN_TTL, OTCAPTCHA_PASS_TTL | 10m, 5m | Life of a challenge; life of a pass. The pass life may exceed the challenge life by at most one minute. |
OTCAPTCHA_IDEMPOTENCY_WINDOW | 20s | An identical server check inside this window gets the same answer, once. |
OTCAPTCHA_POW_BITS, OTCAPTCHA_POW_COUNT | 12, 64 | The invisible tier's work. |
OTCAPTCHA_POW_HARD_BITS, OTCAPTCHA_POW_HARD_COUNT | 13, 96 | The hold and puzzle tiers' work. |
OTCAPTCHA_HOLD_MS | 1500 | Length of the hold. |
OTCAPTCHA_INTERACTIVE_THRESHOLD | 60 | Score from which a hold is asked. |
OTCAPTCHA_RATE_IP_PER_MIN, OTCAPTCHA_RATE_NET24_PER_MIN, OTCAPTCHA_RATE_FP_PER_MIN | 30, 200, 120 | Rates that add to the score; 0 turns the fingerprint rate off. |
OTCAPTCHA_RISK_ENFORCE, OTCAPTCHA_RISK_ENFORCE_IDS | off, empty | Let the measured checks count, for every key or for the listed keys only. |
OTCAPTCHA_ATTACK_MODE, OTCAPTCHA_ATTACK_MIN_PER_MIN | on, 10 | Automatic attack mode, and the solves a minute below which volume alone never raises it. |
OTCAPTCHA_PUZZLE | off | The slide puzzle tier. |
OTCAPTCHA_PUZZLE_SCORE, OTCAPTCHA_PUZZLE_ATTEMPTS, OTCAPTCHA_PUZZLE_TOLERANCE, OTCAPTCHA_PUZZLE_TTL | 80, 3, 4, 2m | Score from which the puzzle is asked, misses allowed, pixels of tolerance, life of a picture. |
OTCAPTCHA_THEMES | empty | The brand look per site key, as JSON. |
OTCAPTCHA_ALLOWED_ORIGINS | empty | Page origins allowed to call the browser routes, comma-separated. https://*.example.com and * are accepted. |
OTCAPTCHA_VALIDATE_ALLOWED_CIDRS | empty, open | Addresses allowed to call the server check. Set it. |
OTCAPTCHA_VALIDATE_MAX_CONCURRENT | 512 | Above this the server check answers 503. |
OTCAPTCHA_TRUSTED_PROXIES, OTCAPTCHA_REAL_IP_HEADER | empty, X-Forwarded-For | Where the visitor's address is read from when a proxy sits in front. |
OTCAPTCHA_EVENTS_DIR, OTCAPTCHA_EVENTS_MAX_MB_PER_DAY, OTCAPTCHA_EVENTS_RETAIN_DAYS | empty, 512, 90 | Records: where, how much a day, how long. |
OTCAPTCHA_REPEATS | on | The within-a-day repeat counters, which are measured only. |
SENTRY_DSN, SENTRY_ENVIRONMENT | empty | Optional error reporting. Reports carry nothing about a visitor. |
The credentials file
A JSON object: an account name, then a list of site keys with their secrets. One site key may appear under several accounts; the lookup is by key. A key must be 32 lowercase hex characters, and a file that fails to parse stops the start.
{
"my-account": [
{ "captcha_id": "[SITE KEY]", "captcha_key": "[SECRET]" }
]
}Pointing the loader at your service
The loader is built with our API address. Pass yours in apiServers, or build the loader from source with your address, which is what a fully self-contained installation does.
otcaptcha.init(
{ captchaId: '[SITE KEY]', apiServers: ['https://captcha.example.com/captcha/v1'] },
(widget) => { /* as usual */ }
);Running more than one instance
Instances share nothing but the store. Attack mode and its baselines live in memory per instance, so a surge is seen at about half its size on each of two instances; the thresholds are set with that in mind. Put the public listeners behind a load balancer that health-checks /health-check, keep the internal listeners reachable from your application servers only, and give Redis a replica with Sentinel so a lost instance moves the primary without anyone acting. Spent-pass records from the last moments before a failover can be lost, which means a pass could be checked twice in that window; nothing else needs restoring.