Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Altcha Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire it up in four steps.

composer require altcha-org/altcha
php artisan vendor:publish --tag=altcha-config  # if the package ships one; otherwise create config/altcha.php

config/altcha.php:

return [
    'hmac_key'               => env('ALTCHA_HMAC_KEY'),
    'hmac_signature_secret'  => env('ALTCHA_HMAC_SIGNATURE_SECRET'),
    'algorithm'              => 'SHA-256',
    'max_number'             => 100_000,
    'expires'                => 600, // seconds the puzzle stays solvable
];

As of v2.3.0 both secrets are required. hmacSignatureSecret is a distinct value from hmacKey, and any server that calls verifySolution must have it configured — see Gotchas.

Bind the library once in AppServiceProvider::register() so you never pass secrets around:

$this->app->singleton(\AltchaOrg\Altcha\Altcha::class, fn () =>
    new \AltchaOrg\Altcha\Altcha(
        config('altcha.hmac_key'),
        hmacSignatureSecret: config('altcha.hmac_signature_secret'),
    )
);

A JSON challenge endpoint (the widget fetches this), plus the route:

// app/Http/Controllers/AltchaChallengeController.php
public function __invoke(Altcha $altcha)
{
    $options = new CreateChallengeOptions([
        'algorithm' => config('altcha.algorithm'),
        'maxNumber' => config('altcha.max_number'),
        'expiresAt' => now()->getTimestamp() + config('altcha.expires'), // int; 0 = never expires
    ]);

    return response()
        ->json($altcha->createChallenge($options))
        ->header('Cache-Control', 'no-store');
}
Route::get('/altcha/challenge', AltchaChallengeController::class)
    ->middleware('throttle:30,1')
    ->name('altcha.challenge');

Drop the widget into any Blade form:

<script type="module" src="https://cdn.jsdelivr.net/npm/altcha/dist/altcha.min.js"></script>

<form method="POST" action="/contact">
    @csrf
    <altcha-widget challengeurl="{{ route('altcha.challenge') }}" name="altcha" auto="onsubmit"></altcha-widget>
    ...
</form>

Where to look first: the README's two examples (createChallenge / verifySolution) are the whole API. Read CreateChallengeOptions to see what you can tune (note expiresAt is now int|float|null), confirm the constructor signature of your pinned version, and check the widget docs for <altcha-widget> attributes (name, auto, language, hidefooter, strings) — that's where 90% of your day-to-day tweaking happens.

First use case: protect the contact/registration form. One GET endpoint, one widget, one validation rule — no database, no third-party service, no cookie banner implications.

Implementation Patterns

A validation rule is the right abstraction. Don't scatter verifySolution calls through controllers:

class ValidAltcha implements ValidationRule
{
    public function __construct(private Altcha $altcha) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! is_string($value) || $value === '') {
            $fail('Please complete the verification.');
            return;
        }

        try {
            if (! $this->altcha->verifySolution($value, true)) { // true = check expiry
                $fail('Verification failed. Please try again.');
            }
        } catch (\Throwable $e) {
            report($e);
            $fail('Verification failed. Please try again.');
        }
    }
}
$request->validate([
    'email' => ['required', 'email'],
    'altcha' => ['required', new ValidAltcha(app(Altcha::class))],
]);

Add a replay guard. ALTCHA solutions are stateless and valid until expiry, so the same payload can be resubmitted. Cheap fix inside the rule, after verification succeeds:

$data = json_decode(base64_decode($value, true), true);
$signature = $data['signature'] ?? null;

// Fall back to a sane TTL when expiresAt is 0 (never expires).
$ttl = config('altcha.expires') ?: 600;

if ($signature && ! Cache::add('altcha:'.$signature, true, now()->addSeconds($ttl))) {
    $fail('This challenge has already been used.');
}

Middleware for broader coverage. If several public routes need protection, extract the rule into EnsureAltchaIsValid middleware, register it as an alias ('altcha'), and apply it to route groups. Keep the verification out of web globally — you don't want it on authenticated or API traffic.

Inertia / Livewire. The widget is a custom element that writes a hidden input, so full-page form posts work out of the box. For Livewire, add wire:ignore around the widget and bridge its event:

<altcha-widget
    wire:ignore
    challengeurl="{{ route('altcha.challenge') }}"
    onverified="this.dispatchEvent(new CustomEvent('altcha-verified', { detail: event.detail, bubbles: true }))"
></altcha-widget>

…or listen for the widget's statechange/verified event and $wire.set('altcha', e.detail.payload). For Inertia, self-host the script via Vite (npm i altcha) and import it in app.js rather than relying on the CDN, so your CSP doesn't need a third-party script-src.

Tuning maxNumber. This is your UX/difficulty dial. 100_000 solves in well under a second on desktop and ~1–2s on a mid-range phone — a reasonable default. Push to 1_000_000 only on high-abuse endpoints and always measure on a throttled mobile profile.

Two verification modes. verifySolution($payload, $checkExpires) is what you want 95% of the time (the widget solved a server-issued challenge). verifyServerSignature($payload) is for when your own server generated the payload and you're checking its HMAC/expiry server-side — useful for signed links or sentinel-style flows where the puzzle is created elsewhere. They return different shapes: a bool vs. a result object with verified and expired flags, so don't mix them up in conditions.

Cross-library interoperability (new in 2.3.0). The canonical JSON used for challenge signing now matches altcha-lib (JS) byte-for-byte. If you generate challenges in Node (e.g. an edge function or a separate service) and verify them in Laravel — or vice versa — they now cross-verify cleanly. Useful pattern: issue challenges from a stateless edge worker, verify in PHP with the same secrets.

Testing. Feature tests can't fabricate a valid payload by hand. Use the library's solver if your pinned version exposes one ($altcha->solveChallenge($challenge)), otherwise brute-force number in a loop against the returned challenge in a test helper and base64-encode the payload. Wrap that in a fakeAltcha() helper and share it — you'll use it on every form test. Remember the test Altcha instance needs a non-empty hmacSignatureSecret too, or every assertion fails for the wrong reason.

Gotchas and Tips

  • v2.3.0 breaking: no signature secret = every verification fails. If you upgrade and only configured hmacKey, verifySolution now returns invalidSignature for everything — including valid, freshly solved challenges. Add ALTCHA_HMAC_SIGNATURE_SECRET to every environment that verifies solutions (web, queue workers, scheduled jobs, any CLI command), not just the challenge issuer. An empty string '' counts as unset on both the create and verify side.
  • Don't decode the payload before passing it. verifySolution() expects the raw base64 string exactly as the widget submits it. json_decode first and it will silently fail verification forever. (Decoding a copy for your replay-guard signature is fine.)
  • Expiry semantics changed in 2.3.0. expiresAt is int|float|null: 0 means no expiry (not "expired at epoch"), fractional timestamps like now()/1000 + 600 are accepted and signed verbatim, and expiry is now checked to the sub-second with no ~1s of grace. If your options build expiresAt from a Carbon instance, switch to a numeric timestamp. Don't set an expiry within a second of your tolerance or users will hit the boundary.
  • Canonical JSON / float caveat. Challenges whose data contained floats formatted differently by PHP than JS are signed differently and fail verification after upgrading. Avoid floats in data (use strings or ints); challenges issued pre-2.3.0 with float data are effectively dead.
  • data shape changed. ChallengeParameters::toArray()['data'] is now a stdClass when data is empty or list-shaped. If you assert on payload shape in tests, log inspection, or hand off to JS, adjust for the object encoding.
  • JsonException on bad UTF-8. createChallenge() and toCanonicalJson() now throw on invalid UTF-8 in data (previously an empty string was silently signed). If data comes from user input, sanitize/mb_convert_encoding or wrap in try/catch and fail the request rather than 500.
  • API signatures drifted across major versions. Older releases took $hmacKey as an explicit second argument on verifySolution; newer ones take it in the constructor, and 2.3.0 adds the signature secret. Pin the version in composer.json and read that tag's README — copy-pasting snippets from different versions is the #1 source of "why is everything invalid".
  • The HMAC key must be identical on the challenge endpoint and the verifying request. Behind a load balancer with per-node envs, or after rotating the key mid-deploy, in-flight puzzles die instantly. Rotate at low traffic and keep the old key around briefly if you can. Same applies to hmacSignatureSecret.
  • config:cache pitfalls. Reading the key via env() outside a config file returns null in production. Always go through config('altcha.hmac_key') / config('altcha.hmac_signature_secret') — a cached config that's missing the signature secret produces the 2.3.0 "everything fails" symptom.
  • Set Cache-Control: no-store on the challenge route. A CDN or Laravel response cache serving the same challenge to everyone is both a security hole and a replay nightmare.
  • Throttle the challenge endpoint. Unguarded, it's a free HMAC-signing oracle. throttle:30,1 per IP is plenty for real users.
  • verifySolution throws on malformed input (bad base64, missing JSON keys). Always wrap in try/catch and fail closed — an unhandled exception on a public form is a 500 for every bot probe.
  • ALTCHA is a cost-imposer, not a bot detector. It raises the price of bulk submissions; it doesn't identify anyone. Pair it with a honeypot field, throttle on the POST route, and a time-to-submit check for anything that matters.
  • Expiry is a UX tradeoff. Too short and slow users get a fresh challenge on submit (the widget re-fetches, which is fine but adds latency). Too long and replays stay viable. Ten minutes is a sane default; the verifySolution boolean flag lets you skip the expiry check for testing only — never in production.
  • Custom element + CSS resets. Tailwind's preflight can nuke the widget's internal styling. Target altcha-widget with the documented CSS variables if the look clashes with your design system, rather than fighting it with !important.
  • Extension points are thin by design. There are no Laravel events or hooks — the library is three stateless methods. Your integration surface is the challenge endpoint (you control options per route by reading a request param or route name) and the validation rule. Keep both thin and versioned in your own code, not sprinkled across controllers.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package