altcha-org/altcha
ALTCHA for PHP offers a privacy-first, self-hosted CAPTCHA alternative using proof-of-work. Generate and verify challenges server-side, protect forms without cookies or tracking, and integrate easily into Laravel/PHP apps. GDPR-friendly and simple to deploy.
Revision note: updated for v2.3.0 (security fix + breaking changes). Prior assessment assumed optional signature verification.
Architecture fit
ALTCHA is a proof-of-work CAPTCHA alternative: the server issues a signed challenge, the browser solves it (hashing until a target is met), and the server verifies an HMAC-signed payload. altcha-org/altcha-lib-php is the server-side half only — challenge creation and payload verification — and is deliberately framework-agnostic, dependency-light PHP. For a Laravel codebase this means it does not "fit" a framework slot out of the box; it sits one layer below where we want it. We would wrap it in a service provider, a config file (HMAC secret, algorithm, maxNumber, expiry), a challenge endpoint/controller, a Rule object or FormRequest extension for verification, and a Blade component hosting the JS widget. That is a routine, well-bounded piece of glue work — roughly a thin internal package. v2.3.0 does not change this shape, but it does make the config file load-bearing: the HMAC secret is now mandatory rather than optional.
Architecturally the attractive property is statelessness: verification is a signature check, no session, no cookie, no third-party network call at request time. That is a genuine fit for a load-balanced Laravel app behind a CDN, and it removes the data-processor relationship we currently carry with reCAPTCHA/hCaptcha. The statelessness claim now depends on the secret being correctly present in every environment — see risk below.
Integration feasibility
High, and marginally higher than before. Composer install, PHP 8.x, no extensions, no migrations, no queue, no external service. The only non-PHP dependency is the client widget script, which can be self-hosted. v2.3.0 also fixes canonical-JSON parity with the JS library, which means a PHP backend can now reliably verify challenges minted by a JS-side or edge-side component (and vice versa) — useful if we ever want to issue challenges at the CDN edge, and useful for local development where the widget and server are separate. Friction points remain outside the library: the Web Crypto API requires a secure context (HTTPS everywhere, including internal staging), CSP needs allowances for the widget script and its worker (script-src, worker-src blob:), and challenge replay protection is our responsibility. If we want single-use challenges we must add a store (Redis or cache) keyed on the challenge signature with a TTL — that converts a stateless design into a stateful one and should be a deliberate decision. New in this release: challenge creation can now throw JsonException on invalid UTF-8 in data, so the wrapper must decide whether that surfaces as a 500 or a graceful fallback.
Technical risk
hmacSignatureSecret was unset (null), the signature check was skipped entirely — unsigned or client-tampered challenges (arbitrary keyPrefix, arbitrary cost) verified successfully. Anyone running the library without configuring a secret had no cryptographic protection at all while appearing to be protected. This is the single most important fact in the release and it directly reprices the "thin third-party validation" concern below.cost-tampered payload is rejected.data, floats formatted differently from JS) will fail verification after upgrade, and sub-second expiry removes the previous ~1s grace. A rolling deploy across a fleet will reject users mid-form. Mitigate with a short expiresAt during the change window, a drain, or a brief dual-verify path.expiresAt semantics changed in a footgun-adjacent way. 0 now means no expiry, fractional timestamps are accepted and signed as-is, and expiry is checked to sub-second. Sub-second checking raises the bar on fleet clock accuracy (NTP) — small skew between nodes is no longer absorbed by the old 1s grace.Key questions
maxNumber satisfies it?data field? If so, does it contain floats or non-UTF-8 input, and are we exposed to the v2.3.0 signature-format change?0 (no expiry) explicitly forbidden by our config validation rather than merely undocumented?Stack fit
Composer-native, PSR-compatible, no framework coupling — it drops into a Laravel service container cleanly. The integration surface is small and well understood: a config/altcha.php, a singleton wrapper around the library's challenge/verify calls, a POST /altcha/challenge route (rate-limited), a validation rule applied to the submitted payload, and a Blade component that renders the widget and its script tag. Nothing touches Eloquent, queues, or the scheduler. Add two things the previous plan didn't need: a config assertion that the secret is non-empty, and an integration test proving a tampered payload is rejected.
Migration path Run in parallel, not as a cutover. Keep the existing CAPTCHA provider behind a config/feature flag with ALTCHA as an alternate driver, and select per-form. Sequence from low-risk to high-risk surfaces: internal/staging forms → newsletter and contact → registration → login and password reset. Instrument solve time, solve failure rate, and submission-to-abuse ratio at each step before widening. A parallel-run period of two to four weeks on the low-risk tier gives us the data to decide whether the high-risk surfaces are appropriate at all.
If ALTCHA is already deployed anywhere on a pre-2.3.0 pin, treat this as an incident-adjacent change: audit first whether a secret was actually configured (if not, we have been running unprotected and should assume the surface was exploitable), then upgrade with a controlled window rather than a routine dependency bump.
Compatibility
Verify the library's PHP constraint against our actual runtime and our oldest supported Laravel version; the package is framework-agnostic so Laravel version risk is near zero, but the wrapper we write is not. v2.3.0 raises expiresAt types to int|float|null and changes ChallengeParameters::toArray()['data'] to stdClass for empty/list-shaped data — audit any wrapper code that type-hints or serialises these. Confirm HTTPS enforcement everywhere the widget runs (secure-context requirement) and that CSP allows the widget script and worker. Confirm no sticky-session requirement, and confirm the challenge endpoint is excluded from CDN caching and response-caching middleware. Confirm fleet-wide NTP sync, since sub-second expiry checking removes the old grace window.
Sequencing
v2.3.0; add a regression test that a payload with a tampered cost/keyPrefix is rejected.Maintenance Low ongoing effort in code volume: small API surface, no database, no background jobs. The real maintenance obligation is the pinned dependency plus the wrapper we own. The v2.3.0 release changes the maintenance posture in two ways: (a) it demonstrates that upstream ships security-relevant fixes, so a quarterly review is too slow for security advisories — subscribe to GitHub security advisories/watch releases and triage within days; (b) it shipped a breaking security fix, so we must be able to test and deploy a pin bump on short notice. Budget a few hours per quarter for routine review, plus the ability to act in hours if another advisory lands. Keep the fork plan current — at 115 stars we cannot contract for upstream cadence.
Support
Community-only support via GitHub issues; no SLA on the OSS library. If we need one, it comes from the vendor's commercial product — a different procurement conversation. Internally the runbook now must cover: secret rotation, "all verifications failing" triage (first check: is the secret set and identical across nodes?), distinguishing invalidSignature from expired in logs, widget-load failure, and the manual-override path for locked-out users. Add an alert on the ratio of invalidSignature to expired failures — v2.3.0 makes a missing secret indistinguishable, to the end user, from a bot attack, so the failure reason code is the thing to monitor.
Scaling
Server-side cost is negligible — HMAC sign and verify, no external calls, horizontally scalable by default. The challenge endpoint is the only new attack surface: it is unauthenticated and cheap to call, so rate-limit it per IP and set Cache-Control: no-store so it can't become a DoS amplifier or a CDN-cached static response. If we add replay protection, the verification path acquires a Redis round-trip and a new failure mode; size that deliberately rather than by default. Client-side cost is the real scaling constraint: keep maxNumber modest and validate on our lowest-spec supported device.
Failure modes
invalidSignature rate.data was empty, list-shaped, or contained floats. Drain or shorten expiry across the change window.expiresAt: 0 set by accident → no expiry at all. The new "0 means never expires" semantics is a footgun; forbid it in config validation and set an explicit TTL.JsonException from invalid UTF-8 in data → challenge creation throws. Only reachable if we pass user-derived data; catch it and fail gracefully rather than 500.Ramp-up A mid-level Laravel engineer should reach productive integration in one to two days; the protocol (salt, challenge, signature, HMAC verification) is small and well documented upstream. That estimate held before and holds now, with one addition: whoever onboards must read the v2.3.0 changelog, because the library now has a hard prerequisite (a configured secret) whose absence is a silent-insecurity bug in older versions and a hard outage in this one. The larger ramp-up cost remains organisational: security review of the threat model, confirmation of what our current deployment's secret configuration actually is, an accessibility sign-off, and getting the CSP and secret-management changes through the platform team. Plan for the people work, not the code.
How can I help you explore Laravel packages today?