# mastodon-altcha-captcha Self-hosted [ALTCHA](https://altcha.org) proof-of-work captcha for a Mastodon sign-up form, built for anti-social.online (2026-09-17) after a bot farm started hammering registrations. No vendor account, no external network call at verify time — the whole thing is ~150 lines of Ruby (stdlib SHA-256 + HMAC-SHA256) plus the vendored widget JS. Deployed as an **untracked local-overrides bundle** — nothing here edits a tracked Mastodon file directly, so `git checkout ` on an upgrade never reverts it. Same pattern as other customizations on this instance (see the character-limit override). ## What's in here | File | Installs to | |---|---| | `zz_altcha.rb` | `config/initializers/zz_altcha.rb` | | `views/auth/registrations/new.html.haml` | `app/views_local/auth/registrations/new.html.haml` | | `altcha.min.js` | `public/local/altcha.min.js` | `zz_altcha.rb` is the whole thing: the ALTCHA protocol implementation (`Local::Altcha`), the challenge-issuing controller, a `prepend_view_path` so the view override above actually gets used, a `before_action` on `Auth::RegistrationsController#create` that gates account creation on a solved challenge, and — separately — a `before_action` on `Api::V1::AccountsController#create` that blocks account creation via the REST API entirely (see "Why the API is blocked" below). `altcha.min.js` is vendored from **npm `altcha@3.2.2`, `dist/main/altcha.min.js` specifically** (MIT license, github.com/altcha-org/altcha). Not `dist/external` — see gotchas. ## Manual deploy steps (not covered by these files) 1. Copy the three files to the paths above and restart `mastodon-web`. 2. Add an HMAC secret to `.env.production`: ``` ALTCHA_HMAC_KEY= ``` 3. nginx (`sites-available/mastodon`), inside the `server { listen 443 ... }` block: ```nginx location = /local/altcha-challenge { 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_pass http://backend; proxy_cache off; # CRITICAL, see gotchas add_header Cache-Control "no-store"; } location ^~ /local/ { add_header Cache-Control "public, max-age=86400"; try_files $uri =404; } ``` Adjust `proxy_pass` target / upstream name to match your vhost. ## Why the API registration endpoint is blocked too Deploying the web-form captcha alone did nothing — logs showed the bot was never hitting the web form. It was using Mastodon's REST API registration flow instead (`POST /api/v1/apps` to mint a throwaway OAuth app, then `POST /api/v1/accounts`), a completely different controller (`Api::V1::AccountsController`) that the web form's captcha can't reach. Decision made here: disable API-based account creation outright rather than extend ALTCHA to the API too. Both options block the same unmodified third-party apps in practice (none of them know to send an `altcha` token, so requiring one is functionally a flat 403 anyway) — disabling is simpler. Existing users' apps (login, posting, everything except *creating a brand new account* in-app) are unaffected. **If your instance actually needs API-based sign-up** (e.g. you rely on users creating accounts through a mobile client), delete that `Api::V1::AccountsController` block in `zz_altcha.rb` and think about a different mitigation for that endpoint specifically. ## Gotchas (both cost a live outage to find) 1. **Use `dist/main`, not `dist/external`.** The `/dist/external` build (the one the altcha docs recommend for strict-CSP setups) never calls `algorithms.set(...)` anywhere in its source — it expects you to separately import and register a worker per algorithm, same as the documented Argon2/Scrypt pattern. Skip that step (easy to, since nothing errors loudly) and every verification attempt fails with `Unsupported algorithm SHA-256`. `dist/main` self-registers SHA-256 (and friends) unconditionally at module load — use that instead, and allow its one small inline `