Skip to main content

ADR-0005: Self-hosted ALTCHA proof-of-work instead of a vendor CAPTCHA

Status: Accepted (retroactive) Date: 2026-07-24

Context

Login and registration needed bot friction beyond rate limiting (ADR-0006). The obvious options — reCAPTCHA and hCaptcha — were rejected because they:

  • ship user telemetry to a third party from a payroll login page;
  • require outbound calls from both browser and backend, which breaks the air-gapped/offline dev and e2e setup (the local stack must work with no internet);
  • add a per-site-key vendor dependency to every per-client stack (ADR-0007).

ALTCHA is an open-source, self-hostable proof-of-work scheme: the server signs a challenge, the browser burns CPU solving it in a WebWorker (WASM), and the server verifies the HMAC — no external service anywhere.

Decision

  • Backend: AltchaService issues HMAC-signed challenges and verifies solutions; AltchaVerificationFilter rejects auth requests lacking a valid X-Altcha-Payload with HTTP 428. Accepted solutions go into an in-memory ConcurrentHashMap replay registry so a payload cannot be submitted twice.
  • Config: altcha.enabled defaults to false (application.yml) so dev/CI/e2e post credentials directly; the production client template hard-enables it and fail-fasts at boot if no HMAC key is set (docker-compose.client.template.yml).
  • Frontend: the widget solves challenges via a WASM worker, which requires CSP allowances — script-src 'wasm-unsafe-eval' and worker-src 'self' blob: in frontend/nginx.conf (the file comments explain that without explicit worker-src, workers fall back to script-src and the widget breaks).

Consequences

Positive

  • Zero third-party calls, zero tracking, zero vendor keys — every client stack is self-contained, and local dev works offline.
  • The 428 status cleanly separates "prove you're human" from 401/429, which the frontend and e2e helpers rely on.
  • Per-client HMAC keys mean one leaked key compromises one client only.

Negative

  • Weaker bot deterrence than vendor CAPTCHAs. Proof-of-work only prices attacks in CPU; there is no behavioral/reputation signal, and a botnet with spare cycles pays the toll happily. ALTCHA is friction, not a wall — the rate limiter and account lockout remain the real defenses.
  • The replay registry is in-memory and resets on restart, so a captured payload from just before a redeploy could be replayed just after, within the challenge-expiry window (30 min default). Accepted for the same one-backend-per-client reason as ADR-0006.
  • The CSP must permanently carry wasm-unsafe-eval + worker-src blob: — a broader script policy than the app would otherwise need.
  • Honest users on slow devices pay a real CPU cost at login.

References