Self-hosted ALTCHA CAPTCHA management with API keys, multi-site support, and statistics.
🌐 Website · 📖 Documentation · 🧩 WordPress plugin
GateCHA is an open-source alternative to ALTCHA Sentinel. It wraps the ALTCHA proof-of-work CAPTCHA protocol with a management layer: API key management, per-site configuration, replay protection, and a dashboard with statistics.
- ALTCHA-compatible - Works with the official ALTCHA widget (MIT)
- API Key Management - Create keys per site with custom difficulty, TTL, and domain restrictions (multiple domains +
*.example.comwildcards) - Replay Protection - Consumed challenges are tracked and rejected on reuse
- Statistics Dashboard - Track challenges issued, verifications (success/fail), per key, per day
- MCP Endpoint - Manage API keys from an AI client (Cursor, Claude Code) over an authenticated MCP server; off by default
- Single Binary - Vue.js dashboard embedded in the Go binary via
go:embed - Multi-Database - SQLite by default; MySQL available in the
mysqlbuild variant - Docker Ready - One container, zero external dependencies (SQLite mode)
- Lightweight - ~23.8MB Docker image, ~14.2MB binary (SQLite); ~24.2MB / ~14.5MB with MySQL support
mkdir -p /opt/docker/GateCHA && cd /opt/docker/GateCHA
wget https://raw.githubusercontent.com/Upellift99/GateCHA/refs/heads/main/docker-compose.yml
docker compose up -dOpen http://localhost:8080 and log in with admin / changeme.
docker run -d -p 8080:8080 \
-v gatecha_data:/app/data \
-e GATECHA_ADMIN_PASSWORD=your-password \
ghcr.io/upellift99/gatecha:latest| Tag | Points to | Use it when |
|---|---|---|
latest |
Newest published release | Default choice |
0.3.3, 0.3 |
That exact release / newest patch in the 0.3 line | You want reproducible upgrades |
main |
Latest commit on main, unreleased |
Testing an unreleased fix |
sha-<commit> |
One specific build | Pinning or bisecting |
Pin 0.3 (or a full version) in production if you'd rather approve minor
upgrades yourself, since latest crosses minor versions as they ship.
GateCHA ships as a single self-contained binary with the web dashboard embedded,
so there are no extra files or runtime dependencies. Grab the archive for your
platform from the latest release
(linux/darwin/windows, amd64/arm64):
# Example: Linux x86_64. Check the releases page for the current version
VERSION=0.3.0
curl -fsSL -o gatecha.tar.gz \
"https://github.com/Upellift99/GateCHA/releases/download/v${VERSION}/gatecha_${VERSION}_linux_amd64.tar.gz"
tar -xzf gatecha.tar.gz
GATECHA_ADMIN_PASSWORD=your-password ./gatechaOpen http://localhost:8080. See Configuration for all options.
# Prerequisites: Go 1.26+, Node.js 20+
git clone https://github.com/Upellift99/GateCHA.git
cd GateCHA
make build # SQLite only (default)
make build-mysql # with MySQL support
./gatechaLog in to the dashboard at http://localhost:8080, go to API Keys, and create a new key.
<script async defer src="https://cdn.jsdelivr.net/npm/altcha/dist/altcha.min.js" type="module"></script>
<form action="/your-endpoint" method="POST">
<!-- your form fields -->
<altcha-widget
challenge="https://your-gatecha-host/api/v1/challenge?apiKey=gk_your_key_id"
></altcha-widget>
<button type="submit">Submit</button>
</form># Example: Python
import requests
altcha_payload = request.form.get('altcha')
resp = requests.post(
'https://your-gatecha-host/api/v1/verify?apiKey=gk_your_key_id',
json={'payload': altcha_payload}
)
if resp.json().get('ok'):
# Valid submission
passAlongside the proof of work, GateCHA can score how a visitor interacted with the page: pointer travel, scroll and touch counts, typing rhythm and timings. This is the Human Interaction Signature (HIS). It records aggregates only, never coordinates, timestamps, key contents or field values.
Load the collector your own instance serves:
<script src="https://your-gatecha-host/api/public/his.js" defer></script>It attaches to any form containing an ALTCHA widget and, on submit, fills a hidden
gatecha_his_signals field with a JSON object. Forward that value to /verify:
import json
resp = requests.post(
'https://your-gatecha-host/api/v1/verify?apiKey=gk_your_key_id',
json={
'payload': request.form.get('altcha'),
'his_signals': json.loads(request.form.get('gatecha_his_signals') or 'null'),
},
)Calling /verify from the browser instead? Read the same object from
window.gatechaHIS.signals().
HIS does not block until you say so. Out of the box it runs in Monitor mode: scores are recorded and surfaced on the dashboard and per key, and every verification outcome is decided by the proof of work alone. Enabling HIS sampling on a key additionally stores the raw aggregates so the key detail page can show you the score distribution. Blocking is a separate per-key switch, covered in Blocking on the score.
To build your own collector, his_signals is this object, all numeric:
| Field | Meaning |
|---|---|
duration_ms |
Length of the observed interaction window |
time_to_first_ms |
Delay until the first interaction event, -1 if none |
pointer_events |
Sampled pointer/mouse move events |
pointer_distance |
Total pointer path length, CSS pixels |
scrolls |
Scroll events |
touches |
Touch events |
keydowns |
Key-down events |
key_interval_stdev_ms |
Standard deviation of inter-keydown intervals |
The collector fails quietly by design: a form that never gets a hidden field just submits normally. So verify it rather than assume it.
The counter that answers the question is HIS Observations on the dashboard
overview, with a per-key equivalent on the HIS Monitor line of each key detail
page. It counts every /verify call that carried his_signals, whether or not
sampling is on. If it stays at zero after real submissions, the signals are not
reaching /verify and the collector is the thing to look at, not the score.
Three more places worth a glance:
- the
/verifyresponse carrieshis_bot_scorewhenever signals were received, and omits it entirely when they were not; - the browser console warns when the collector loads on a page with no form it can
attach to, which catches a widget mounted outside the
<form>; - the calibration histogram on the key detail page needs HIS sampling switched on, and only stores samples from that moment forward, so a freshly enabled key shows nothing until new traffic arrives.
The usual causes of a stuck counter are a widget sitting outside the <form>, a
form posted with fetch that fires no native submit event (read
globalThis.gatechaHIS.signals() yourself in that case), and a backend that does
not forward the hidden gatecha_his_signals field into the /verify body.
When a request carries his_signals, /verify returns the Monitor score
alongside the verification outcome, so your backend can apply its own threshold
without waiting for server-side enforcement:
{ "ok": true, "his_bot_score": 0.7, "his_bot_suspected": false }his_bot_score runs from 0 to 1 where higher means more bot-like. This is the
reverse of reCAPTCHA's convention, where the score measures confidence that the
visitor is human. Copying a reCAPTCHA rule across ("reject below 0.6") would
reject your humans and pass the bots.
his_bot_suspected is that score judged against the key's suspect threshold
(>= 0.8 unless you changed it), the same rule the dashboard counters and any
blocking use, for when you would rather not own a number.
Two things to know before picking a threshold:
- Both fields are absent when the request carried no
his_signals. Absent is not 0. A visitor whose collector never ran is not thereby a human, so treat the missing fields as "no opinion" rather than as a clean score. - The score moves in steps of 0.1, being a handful of additive penalties and credits, so 0.6 against 0.8 is a real choice while 0.65 against 0.7 is not. Read it as a few tiers, not as a probability. A submission with no motion at all sits at 0.7 on its own, deliberately below the suspect threshold, because keyboard-only and assistive-technology users look like that too.
The fields ride along with failed verifications as well, which are often the ones worth inspecting.
Once you have read your own histogram, a key can reject suspected requests itself instead of only reporting them. Two per-key settings, both on the key's edit page:
- Block suspected bots, off by default;
- Suspect threshold,
0.8by default, anywhere in(0, 1].
A rejected verification answers 200 with the usual failure shape, plus the score
that caused it, and counts as a failed verification in the statistics:
{ "ok": false, "error": "bot_suspected", "his_bot_score": 0.9, "his_bot_suspected": true }Four things worth knowing before you switch it on:
- It only ever acts on requests that carried
his_signals. A site whose collector never runs sends nothing, is scored not at all, and passes untouched whatever the switch says. Enforcement cannot lock out an integration that never had HIS in the first place. - The proof of work keeps precedence. A submission that failed the maths is
reported as
invalid_solution, never asbot_suspected, so a broken widget is not misread as an automation wave. - The threshold is one number with one meaning. It drives the blocking, the
reported
his_bot_suspected, the Monitor counters and the calibration marker together. Lowering it therefore raises the bot-suspected figure on your dashboard by design; that is the same question asked with your number. - Think hard before going at or below 0.70. That is the exact score of a sample whose collector ran and observed nothing at all, and a keyboard-only or screen-reader visitor produces it just as an automated submission does. On some sites that tier is overwhelmingly automation, and the operators who have checked do lower the threshold; it is not a default anyone should inherit unread.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/challenge |
Generate a PoW challenge |
POST |
/api/v1/verify |
Verify a solution |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/public/his.js |
Client-side HIS collector, see step 4 |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/admin/login |
Authenticate |
GET |
/api/admin/keys |
List API keys |
POST |
/api/admin/keys |
Create API key |
GET/PUT/DELETE |
/api/admin/keys/:id |
Manage API key |
POST |
/api/admin/keys/:id/rotate-secret |
Rotate HMAC secret |
GET |
/api/admin/stats/overview |
Global statistics |
GET |
/api/admin/stats/keys/:id |
Per-key statistics |
GET |
/healthz |
Health check |
| Method | Endpoint | Description |
|---|---|---|
POST |
/mcp |
MCP server for API key management. Off by default, answers 404 until enabled. See MCP Endpoint |
GateCHA can expose its API key management as an MCP server, so an AI client such as Cursor or Claude Code can list, create and update keys without you opening the dashboard.
The endpoint is off by default and has to be turned on deliberately. It is a second
authentication path to full admin capability, one that bypasses the dashboard login. While
it is off, /mcp answers 404 and no token is accepted.
In the dashboard, go to Settings and find the MCP Access panel:
- Switch MCP endpoint on.
- Create a token, naming it after the person or machine that will use it. Issue one token per person: they are revoked individually, so revoking one does not disturb anyone else.
- Tick Read only for a token that must never change anything. A read-only token is given a server on which the write tools were never registered, so they are neither listed nor callable.
- Copy the secret. It starts with
gm_, it is shown once, and it cannot be retrieved afterwards. Only its first characters are kept, so the list can tell tokens apart.
The token list shows when each token was last used, which is what tells you a token is dormant and can be revoked.
The endpoint speaks streamable HTTP at /mcp and authenticates with a bearer token. Any
client that can send an Authorization header works, and no OAuth setup is involved.
Cursor (~/.cursor/mcp.json, or .cursor/mcp.json in a project):
{
"mcpServers": {
"gatecha": {
"url": "https://captcha.example.com/mcp",
"headers": {
"Authorization": "Bearer ${env:GATECHA_MCP_TOKEN}"
}
}
}
}Claude Code (.mcp.json):
{
"mcpServers": {
"gatecha": {
"type": "http",
"url": "https://captcha.example.com/mcp",
"headers": {
"Authorization": "Bearer ${GATECHA_MCP_TOKEN}"
}
}
}
}Keep the token in an environment variable rather than in the file itself. The token is
only read from the Authorization header: it is deliberately not accepted in the query
string, because URLs end up in proxy logs and browser history.
| Tool | Read-only token | Description |
|---|---|---|
list_keys |
yes | List keys, with an optional case-insensitive search on name, domain and key ID |
get_key |
yes | Fetch one key by ID |
create_key |
no | Create a key. The HMAC secret is returned here and nowhere else |
update_key |
no | Change a key's settings. Omitted fields keep their current value |
enable_key |
no | Enable a key so it serves challenges again |
disable_key |
no | Disable a key. The site using it stops being able to issue or verify challenges |
Three deliberate limits are worth knowing about:
- The HMAC secret is returned by
create_keyonly. The type the other tools return has no field for it, so no tool can leak it by forgetting to strip it. - Enabling and disabling are their own tools, not a field on
update_key. Taking a site's CAPTCHA down shows up under its own name in the consent prompt your client displays, instead of hiding inside a generic update, andupdate_keycannot do it at all. - There is no delete tool. Removing a key breaks a live site with no undo, so it stays a dashboard action.
The default build is SQLite-only for a lightweight single-binary deployment. MySQL support is compiled in only when explicitly requested.
Build locally with MySQL support:
make build-mysqlDocker image with MySQL support:
docker build --build-arg BUILD_TAGS=mysql -t gatecha:mysql .Docker Compose with MySQL:
docker compose -f docker-compose.mysql.yml up -dNote for contributors: When updating Go dependencies while working on MySQL support, run
go mod tidy -tags mysqlinstead of plaingo mod tidyto preserve the MySQL driver ingo.mod.
| Variable | Default | Description |
|---|---|---|
GATECHA_LISTEN_ADDR |
:8080 |
Listen address |
GATECHA_DB_DRIVER |
sqlite |
Database driver: sqlite always available; mysql requires the mysql build variant |
GATECHA_DB_DSN |
./data/gatecha.db |
Database DSN: file path for SQLite, connection string for MySQL (e.g. user:pass@tcp(host:3306)/dbname?parseTime=true) |
GATECHA_SECRET_KEY |
(auto-generated) | JWT signing secret |
GATECHA_ADMIN_USERNAME |
admin |
Admin username |
GATECHA_ADMIN_PASSWORD |
(auto-generated) | Admin password |
GATECHA_LOG_LEVEL |
info |
Log level |
GATECHA_CLEANUP_INTERVAL |
10 |
Cleanup interval (minutes) |
GATECHA_HIS_SAMPLE_RETENTION_DAYS |
30 |
Retention (days) for opted-in raw HIS calibration samples |
GATECHA_CORS_ALLOW_ALL |
false |
Allow CORS from any origin |
GATECHA_TRUST_PROXY |
false |
Trust X-Forwarded-For/X-Real-IP for the client IP. Set to true when behind a reverse proxy (see note below) |
GATECHA_ENABLE_HSTS |
false |
Send the Strict-Transport-Security header (enable only when always served over HTTPS) |
GATECHA_MAX_BODY_BYTES |
1048576 |
Maximum accepted request body size, in bytes |
GATECHA_RATE_LIMIT_ENABLED |
true |
Enable per-IP rate limiting |
GATECHA_RATE_LIMIT_LOGIN |
5 |
Admin login requests per minute, per IP |
GATECHA_RATE_LIMIT_API |
60 |
Public API (/api/v1/*) requests per minute, per IP |
⚠️ Behind a reverse proxy, setGATECHA_TRUST_PROXY=true. Per-IP rate limiting keys off the connecting IP. WithGATECHA_TRUST_PROXY=falsebehind a proxy, that IP is the proxy itself, so every visitor shares a single rate-limit bucket. It exhausts almost immediately, the public ALTCHA challenge endpoint starts returning429s, and the login captcha breaks with "Expected application/json, received text/html". EnablingTRUST_PROXYmakes the limiter use each visitor's real IP. Only enable it behind a trusted proxy that setsX-Forwarded-For/X-Real-IP, otherwise clients can spoof their IP.
MIT - see LICENSE.