A real HTTP redirect doesn't make sense for a JSON API client -- most either ignore a 3xx or follow it into an HTML page where they expected JSON, which is worse than a clean failure. Returning error text that includes the web sign-up URL is what actually reaches a human, since most Mastodon apps surface that string on-screen when a request fails.
mastodon-altcha-captcha
Self-hosted ALTCHA 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 <tag> 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)
- Copy the three files to the paths above and restart
mastodon-web. - Add an HMAC secret to
.env.production:ALTCHA_HMAC_KEY=<openssl rand -hex 32> - nginx (
sites-available/mastodon), inside theserver { listen 443 ... }block:Adjustlocation = /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; }proxy_passtarget / 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)
-
Use
dist/main, notdist/external. The/dist/externalbuild (the one the altcha docs recommend for strict-CSP setups) never callsalgorithms.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 withUnsupported algorithm SHA-256.dist/mainself-registers SHA-256 (and friends) unconditionally at module load — use that instead, and allow its one small inline<style>tag via a CSP hash (see thecontent_security_policyblock at the bottom ofzz_altcha.rbfor the exact pattern — same technique Mastodon's own CSP initializer uses for PgHero). -
Cache-bust the vendored JS.
public/local/gets a longCache-Control(fine — it's an immutable-ish vendored file), but if you ever updatealtcha.min.jsin place at the same URL, browsers that already cached the old copy will keep serving it for the full max-age, with no visible error pointing at caching as the cause. The view computes a content hash at render time and appends it as a query param:- altcha_js_digest = Digest::MD5.file(Rails.public_path.join('local', 'altcha.min.js')).hexdigest[0, 10] %script{ src: "/local/altcha.min.js?v=#{altcha_js_digest}", type: 'module', async: true, defer: true }so any future swap of the file is automatically a new URL. Don't reference the bare filename directly if you ever update the widget.
-
Rails.application.routes.appendgets shadowed by Mastodon's own catch-all 404 route. Use.prependfor any new route added from an initializer, not.append(append is for routes meant to be tried last, e.g. an engine's fallback). -
Defining a bare
ActionController::Basesubclass at initializer top-level crashes boot (NameError: uninitialized constant AccountsHelper) —ActionController::Base'sinheritedhook eagerly runshelper :all, which isn't safe before eager loading finishes. Define it insideRails.application.config.to_prepare do ... endinstead.
Protocol notes
Ported by hand from github.com/altcha-org/altcha-lib's v1 (classic)
algorithm — src/v1/index.ts — not the newer v2 KDF-based protocol in the
same library. Salt embeds an expiry (?expires=<unix_ts>&), challenge is
sha256(salt+number), signature is hmac_sha256(secret, challenge), and the
server never sends the client the actual number — the widget brute-forces
it. Verification recomputes both from the payload's own fields and compares;
a one-time-use guard via Rails.cache prevents replay within the expiry
window (the core protocol alone doesn't).
Difficulty is Local::Altcha::DEFAULT_MAX_NUMBER (currently 100_000,
sub-second solve time on modern hardware). This stops a script that doesn't
render pages at all (the observed bot's actual behavior) and raises the cost
of automation — it is not cryptographically unbreakable. The algorithm is
published; a sufficiently motivated attacker could reimplement the solver.
Raise the difficulty if that ever becomes the actual threat model.