danielm / laravel-simple-altcha
ALTCHA (proof-of-work captcha) for Laravel: challenge endpoint, validation rule, middleware and Inertia/React stubs. Built on altcha-org/altcha v2.
Requires
- php: ^8.2
- altcha-org/altcha: ^2.3
- illuminate/contracts: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
ALTCHA proof-of-work captcha for Laravel, built on
altcha-org/altcha v2. Includes:
- a challenge endpoint (
GET /altcha/challenge) - a
ValidAltchavalidation rule and analtcharoute middleware - replay protection (a solved payload works once)
- a React / Inertia component published as a stub
Install
composer require danielm/laravel-simple-altcha npm install altcha
.env:
# php -r "echo bin2hex(random_bytes(32)), PHP_EOL;" ALTCHA_HMAC_SECRET=
All supported variables are documented in .env.example.
Optional publishing:
php artisan vendor:publish --tag=altcha-config # config/altcha.php php artisan vendor:publish --tag=altcha-lang # translations php artisan vendor:publish --tag=altcha-react # React component + JSX types
The altcha-react tag copies:
resources/js/components/altcha-widget.tsxresources/js/types/altcha.d.ts
Backend
Validation rule (implicit, so a missing field also fails):
use Danielm\LaravelSimpleAltcha\Rules\ValidAltcha; $request->validate([ 'email' => ['required', 'email'], 'altcha' => [new ValidAltcha], ]);
Or middleware:
Route::post('/contact', ContactController::class)->middleware('altcha');
Or directly:
$result = app(\Danielm\LaravelSimpleAltcha\AltchaManager::class)->verify($request->input('altcha')); $result->verified; // bool $result->reason; // missing | malformed | expired | invalid | replayed
Frontend (Inertia + React)
import { useRef } from 'react'; import { useForm } from '@inertiajs/react'; import { AltchaWidget, type AltchaHandle } from '@/components/altcha-widget'; export default function Contact() { const altcha = useRef<AltchaHandle>(null); const { data, setData, post, processing, errors } = useForm({ email: '', altcha: '' }); const submit = (e: React.FormEvent) => { e.preventDefault(); post('/contact', { // payloads are single-use, so always get a fresh one afterwards onFinish: () => altcha.current?.reset(), }); }; return ( <form onSubmit={submit}> <input value={data.email} onChange={(e) => setData('email', e.target.value)} /> <AltchaWidget ref={altcha} onChange={(payload) => setData('altcha', payload)} /> {errors.altcha && <p>{errors.altcha}</p>} <button disabled={processing || !data.altcha}>Send</button> </form> ); }
challengeUrl defaults to /altcha/challenge. If you change altcha.route.path,
pass the new URL. Extra props are forwarded to <altcha-widget> as attributes.
Configuration notes
- Algorithm:
pbkdf2(default, no extensions),argon2id(ext-sodium),scrypt(ext-scrypt).costmeans different things per algorithm. Your widget build must support the one you choose. - Replay protection uses
Cache::add(). Use redis/memcached/database in production; thefiledriver is not atomic. Keys are derived from the challenge's HMAC signature, so re-encoding a payload does not bypass it. - Challenge route is intentionally outside the
webgroup (no session or cookies) and throttled by default. Adjustaltcha.route.middleware. - Widget requires HTTPS (localhost is fine for development).
Testing in your app
Set ALTCHA_TESTING_BYPASS=let-me-in in .env.testing, then post
['altcha' => 'let-me-in']. The bypass only works when the app environment
is testing.
Package tests
composer install
composer test