druidfi/symfony-altcha-bundle
Symfony bundle that integrates Altcha, a privacy-friendly proof-of-work CAPTCHA alternative, into your forms. Adds spam protection without third-party tracking, cookies, or frustrating image puzzles, with straightforward configuration for Symfony applications.
What this package actually is: druidfi/symfony-altcha-bundle is a Symfony bundle wrapping altcha-org/altcha and the Altcha web widget. Altcha is a proof-of-work CAPTCHA alternative — no Google, no third-party requests, GDPR-friendly, self-hosted.
Reality check for a Laravel developer: you cannot composer require this bundle and expect it to boot in Laravel. It registers a Bundle class, a Configuration tree, and (as of 1.0.5) Symfony's RateLimiter, none of which Laravel's container resolves. Treat this repo as a reference implementation, and pull in the underlying library directly:
composer require altcha-org/altcha
Where to look first (in the repo, in this order):
DependencyInjection/Configuration.php — the full config surface: HMAC key, algorithm, cost, expiry, and (new in 1.0.5) rate limiting + display mode.Altcha instance — this is where the HMAC key and challenge options are bound.First use case: protect login/register. Two moving parts only — a GET endpoint returning a challenge, and a server-side verify call.
// routes/web.php
Route::get('/altcha/challenge', fn () => response()
->json(Altcha::createChallenge(['hmacKey' => config('altcha.hmac_key')]))
->header('Cache-Control', 'no-store')
)->middleware('throttle:altcha')->name('altcha.challenge');
{{-- resources/views/components/altcha.blade.php --}}
<script src="{{ Vite::asset('node_modules/altcha/dist/altcha.min.js') }}" defer></script>
<altcha-widget challengeurl="{{ route('altcha.challenge') }}" name="altcha"></altcha-widget>
// FormRequest / Rule
Altcha::verifySolution($this->input('altcha'), config('altcha.hmac_key'), true); // true = check expiry
Then add config/altcha.php with hmac_key => env('ALTCHA_HMAC_KEY') and generate the key once with bin2hex(random_bytes(32)).
One widget per form, one hidden field. Name the field altcha everywhere and add a FormRequest rule so it validates uniformly:
protected function prepareForValidation(): void
{
// keep the payload out of validated(), it is a one-shot token
$this->merge(['_altcha' => $this->input('altcha')]);
}
Verify in withValidator or a custom Rule object, and strip altcha from validated() before passing to the model.
Challenge endpoint as a stateless GET. This is the pattern the bundle enforces and it maps cleanly to Laravel:
Cache-Control: no-store — the endpoint is per-request by design.Altcha::createChallenge() array; let the widget consume it.IP rate limiting (new in 1.0.5). The bundle now throttles the challenge endpoint per client IP. Mirror it — the endpoint does a non-trivial hash loop per hit, so it's a cheap CPU-exhaustion vector:
// app/Providers/AppServiceProvider.php
RateLimiter::for('altcha', fn (Request $r) => [
Limit::perMinute(30)->by($r->ip()),
Limit::perMinute(200)->by('altcha:global'),
]);
Keying on $r->ip() is only correct if TrustProxies is configured for your load balancer; otherwise 10k users behind Cloudflare share one bucket and real users get 429s.
Display modes (new in 1.0.5). Display mode is now configurable server-side, so the bundle can render the widget as inline vs. floating/compact without touching templates. In Laravel, express this as a Blade component prop so each form chooses:
<x-altcha display="floating" />
Keep the default inline for accessibility — floating widgets can cover submit buttons on small viewports.
Test helper, not test bypass. Don't disable verification in tests wholesale; you'll never notice a broken form. Instead, pre-solve in the test:
private function solveAltcha(): string
{
$challenge = Altcha::createChallenge(['hmacKey' => config('altcha.hmac_key')]);
return Altcha::solveChallenge($challenge['challenge'], ...)[/* base64 payload */];
}
And keep one feature test asserting a 422 for a missing/forged payload.
Replay protection is on you. verifySolution is stateless: the same valid payload can be re-submitted for the lifetime of its expiry. Add Cache::add('altcha:'.$signature, true, $ttl) after successful verification and reject on collision.
Livewire / Inertia specifics. The solution is consumed on first verify, so on a validation failure the user needs a new challenge. In Livewire, dispatch a browser event that calls widget.reset(); in Inertia, force a full reload of the widget after a 422.
composer require druidfi/symfony-altcha-bundle in a Laravel app pulls symfony/http-kernel and friends and gives you zero working behaviour. Read the source, install altcha-org/altcha.bootstrap/cache/config.php after an env change — php artisan config:clear before you debug anything else.TrustedProxy config, $request->ip() is your LB. With a misconfigured trusted proxy list, an attacker can spoof X-Forwarded-For and dodge the limiter entirely. This is the sharpest edge on the 1.0.5 feature.RateLimiter defaults to a cache pool; the file store serializes on locks under load. Use Redis in Laravel — a file-backed limiter on a hot login page becomes its own bottleneck.verifySolution(..., true) with no visible reason. Either raise expires in the challenge options or reset the widget on every submit attempt.worker-src 'self' blob: and script-src covering your widget source. Symptom is a widget that renders a checkbox and never progresses — browser console shows the worker being blocked, not a PHP error.altcha field is base64-encoded JSON containing algorithm, challenge, salt, signature, number, expires. Decode it first; 90% of "it just doesn't verify" is a key/salt mismatch, not a code bug.<form> and injects on submit. Custom @submit.prevent handlers, Livewire forms without a real submit button, or a widget placed outside the form will render but never attach.How can I help you explore Laravel packages today?