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:
AltchaServiceissues HMAC-signed challenges and verifies solutions;AltchaVerificationFilterrejects auth requests lacking a validX-Altcha-Payloadwith HTTP 428. Accepted solutions go into an in-memoryConcurrentHashMapreplay registry so a payload cannot be submitted twice. - Config:
altcha.enableddefaults 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'andworker-src 'self' blob:infrontend/nginx.conf(the file comments explain that without explicitworker-src, workers fall back toscript-srcand 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
../../backend/src/main/java/com/motorph/payroll/security/altcha/AltchaService.java../../backend/src/main/java/com/motorph/payroll/security/altcha/AltchaVerificationFilter.java../../frontend/nginx.conf— CSP allowances for the widget../../docker-compose.client.template.yml—ALTCHA_ENABLED: "true"in production../security/authentication.md— §1b