dskripchenko/laravel-local-captcha

A captcha decided between the browser and your own application: proof of work, timing and a honeypot, with no third-party service.

Maintainers

Package info

github.com/dskripchenko/laravel-local-captcha

pkg:composer/dskripchenko/laravel-local-captcha

Transparency log

Statistics

Installs: 69

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.0.2 2026-08-11 12:52 UTC

This package is auto-updated.

Last update: 2026-08-13 07:51:16 UTC


README

A captcha decided entirely between the visitor's browser and your own application. No third-party service, no account to hold, no key to rotate, no visitor data handed to anyone.

🌐 English · Deutsch · Русский · 中文

What it stops, and what it does not

Read this before choosing it. The trade is real in both directions.

It stops scripted mass submitters — the form-fillers sweeping the internet, which is nearly all of the automated traffic a small site actually sees. They do not run JavaScript, do not wait, and fill every field they find. Three independent layers each catch that.

It does not stop someone who reads this code. Neither does proof-of-work in general: a puzzle calibrated to be tolerable on a phone is trivial for a server. That gap is not a flaw in the calibration, it is the honest limit of the technique.

It does not stop human solving farms. Nothing does, including the hosted services.

It has no reputation signal, and cannot have one. What makes reCAPTCHA, Turnstile and SmartCaptcha strong is seeing the same browser across millions of sites. This sees one submission on one form. That is not a smaller version of the same thing — it is a different thing, and no amount of work here closes the gap.

What you get instead: it cannot be blocked by geography, because there is nothing to block; it works in an air-gapped or firewalled deployment, where a hosted captcha simply never loads; and it sends nothing about your visitors anywhere, which removes a data-residency question rather than answering one.

Requirements

PHP 8.2–8.5 · Laravel 11 / 12 / 13.

Proof of work requires JavaScript in the browser. So does every hosted captcha, so this is not a concession this package makes and the others avoid — but if your form must work without JavaScript, turn that layer off and rely on the other two.

Install

composer require dskripchenko/laravel-local-captcha
php artisan vendor:publish --tag=local-captcha-assets

The service provider is auto-discovered. Publish the config if you want to edit it directly rather than through the environment:

php artisan vendor:publish --tag=local-captcha-config

Use

Put the component in the form and the rule in the validation:

<form method="POST" action="/leads" data-local-captcha>
    @csrf
    <input name="email" type="email">

    <x-local-captcha-field />

    <button type="submit">Send</button>
</form>
$request->validate([
    'email' => 'required|email',
    'captcha' => 'local_captcha',
]);

The component renders the hidden solution field, the decoy, and the script tag. The script fetches a challenge on page load and solves it while the visitor is still typing, so the work overlaps with filling the form instead of becoming a wait after the button is pressed.

To drive it yourself:

LocalCaptcha.attach(document.querySelector('#my-form'), {
    endpoint: '/local-captcha/challenge',
    field: 'captcha',
});

The form emits local-captcha:ready, local-captcha:failed and local-captcha:blocked so you can show progress or an error.

How it works

The server picks a number in [0, max] and publishes the hash of it, together with a random salt, an expiry, and an HMAC signature over all of it. The browser finds the number by trying them in order. The server checks the answer with a single hash.

Two properties of that shape are worth naming, because the more familiar "leading zeros" proof-of-work does not have them:

  • The work is bounded. A leading-zeros target has no upper limit on attempts, so an unlucky visitor can spin for many times the average while the median visitor sees nothing wrong. A range gives a worst case you can state, which is what makes the difficulty setting answerable.
  • Issuing costs nothing and stores nothing. Challenges are signed, not saved. Only spent challenges are recorded, and only for as long as they could still be answered. Storing at issue time would turn the public challenge endpoint into a way to fill your cache — cheap for the caller, expensive for you.

Each layer refuses on its own:

Layer Catches
pow Clients that do not run JavaScript, and replayed or forged challenges
timing Submissions faster than a person could have filled the form in
honeypot Anything that fills a field hidden from view

Configure

LOCAL_CAPTCHA_MAX=30000
LOCAL_CAPTCHA_MIN_SECONDS=3
LOCAL_CAPTCHA_LIFETIME=900
Key Default Meaning
enable true Turn the whole check off (local development, tests)
layers.pow true Browser solves the search
layers.timing true Minimum time between issue and submit
layers.honeypot true Decoy field
max 30000 Size of the search space
lifetime 900 Seconds a challenge stays answerable
min_seconds 3 Least believable fill-in time
route.uri local-captcha/challenge Where challenges are issued; null registers no route
route.middleware throttle:300,1 Rate limit on issuing (see below)
fields.solution captcha Request field carrying the solution
fields.honeypot website Name of the decoy
cache.store default store Where spent challenges are remembered

Calibrating max

Set it low. Lower than feels right.

Against the traffic this actually stops, the difficulty does not matter at all: a scripted form-filler does not run JavaScript, so it fails at any setting, including the smallest one. Difficulty only bites an attacker driving a headless browser — and against that, no setting a visitor would tolerate is enough anyway. So every millisecond of it is paid by real visitors and none of it by the bots you are catching.

That leaves device spread as the only thing to weigh. Measured on a desktop at 100000: median 263 ms, worst of eight runs 388 ms, page load included. A mid-range phone is a handful of times slower than that; an old one can be fifty, which puts the same setting into double-digit seconds for the visitor least likely to wait.

30000 is a reasonable default for a public form: imperceptible on a desktop, a few seconds at worst on old hardware. Raise it only if you have a measured reason to, and remember what the extra work does not buy.

Why the rate limit is loose

Issuing a challenge costs one random number and two hashes and stores nothing — a design goal, so that a public endpoint could not be turned into a resource to exhaust. There is very little for a tight limit to protect.

What a tight limit does break is ordinary visitors. A page carrying the form asks for a challenge on every load, and behind a corporate NAT many people arrive as one address. They hit the limit, the challenge never arrives, the puzzle never solves, and the form silently stops working with nothing on screen to explain it — the exact failure mode this package exists to avoid.

The first default of 60/minute was too tight: a routine end-to-end run tripped it from a single address.

The cache store must be shared

Spent challenges live in the cache. Every process serving the site has to see the same one — with a per-process store on more than one server, each remembers different challenges and a solution works once per server.

Verdicts

LocalCaptcha::verify() returns a Verdict, not a boolean, so a refusal can be logged with its reason. This matters operationally: a rise in expired means visitors are taking longer than the lifetime allows, while a rise in replayed means someone is automating against you. Those call for opposite responses, and a boolean cannot tell them apart.

passed · malformed · forged · expired · replayed · wrong · too_fast · honeypot

Tests

composer test
php tests/browser-contract.php   # needs node

The second one matters more than its size suggests. Everything the PHPUnit suite can reach is PHP re-implementing what the browser is supposed to do, and a suite like that stays green while the two sides drift apart. browser-contract extracts the worker from the file that actually ships and runs it against a real challenge.

License

MIT — see LICENSE.md.