Skip to content

Repository files navigation

GateCHA

Go Docker License: MIT Go Report Card

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.

Features

  • 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.com wildcards)
  • 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 mysql build 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

Quick Start

Docker Compose (recommended)

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 -d

Open http://localhost:8080 and log in with admin / changeme.

Docker Run

docker run -d -p 8080:8080 \
  -v gatecha_data:/app/data \
  -e GATECHA_ADMIN_PASSWORD=your-password \
  ghcr.io/upellift99/gatecha:latest

Image tags

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.

Prebuilt binary (no Docker)

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 ./gatecha

Open http://localhost:8080. See Configuration for all options.

From Source

# 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
./gatecha

Usage

1. Create an API Key

Log in to the dashboard at http://localhost:8080, go to API Keys, and create a new key.

2. Add the Widget to Your Site

<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>

3. Verify on Your Backend

# 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
    pass

4. Optional: collect interaction signals (HIS)

Alongside 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

Checking that the signals actually arrive

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 /verify response carries his_bot_score whenever 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.

Reading the score back

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.

Blocking on the score

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.8 by 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 as bot_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.

API Endpoints

Public (API Key auth via ?apiKey=gk_xxx)

Method Endpoint Description
GET /api/v1/challenge Generate a PoW challenge
POST /api/v1/verify Verify a solution

Public (no auth)

Method Endpoint Description
GET /api/public/his.js Client-side HIS collector, see step 4

Admin (JWT auth via Authorization: Bearer)

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

MCP (MCP token auth via Authorization: Bearer gm_xxx)

Method Endpoint Description
POST /mcp MCP server for API key management. Off by default, answers 404 until enabled. See MCP Endpoint

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.

1. Turn it on and issue a token

In the dashboard, go to Settings and find the MCP Access panel:

  1. Switch MCP endpoint on.
  2. 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.
  3. 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.
  4. 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.

2. Point your client at it

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.

3. Available tools

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_key only. 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, and update_key cannot 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.

MySQL Support

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-mysql

Docker 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 -d

Note for contributors: When updating Go dependencies while working on MySQL support, run go mod tidy -tags mysql instead of plain go mod tidy to preserve the MySQL driver in go.mod.

Configuration

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, set GATECHA_TRUST_PROXY=true. Per-IP rate limiting keys off the connecting IP. With GATECHA_TRUST_PROXY=false behind 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 returning 429s, and the login captcha breaks with "Expected application/json, received text/html". Enabling TRUST_PROXY makes the limiter use each visitor's real IP. Only enable it behind a trusted proxy that sets X-Forwarded-For/X-Real-IP, otherwise clients can spoof their IP.

License

MIT - see LICENSE.

About

Self-hosted ALTCHA CAPTCHA management server with API keys, multi-site support, and statistics dashboard. Open-source alternative to ALTCHA Sentinel.

Resources

Stars

14 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages