seoultra / seo-captcha
Free, open-source, privacy-friendly captcha: invisible proof-of-work widget + PHP verification. No tracking, no cookies, no third-party requests — a self-hosted reCAPTCHA alternative.
Requires
- php: >=8.1
- ext-json: *
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
Provides
None
Conflicts
None
Replaces
None
README
Free, open-source, privacy-friendly captcha — a self-hosted reCAPTCHA alternative. The visitor's browser quietly solves a small proof-of-work puzzle; your own server checks the answer. No tracking, no cookies, no third-party requests, no image puzzles.
- Website & live demo: https://seoultra.id/features/seo-captcha/
- PHP package:
seoultra/seo-captcha· widget:dist/seo-captcha.js(~14 KB gzipped) - License: MIT
browser your server
─────── ───────────
widget ──── GET /captcha.php?scope=… ───▶ createChallenge(): random salt, 4 targets
◀─── signed challenge (JSON) ──── t_i = SHA-256("salt:i:x_i"), HMAC over every field
solve x_i in Web Workers (~0.2 s laptop,
~1 s older phone)
form post with token ───────────────────▶ verify(): signature · expiry · scope · min time ·
4 hashes · single use → ok / reason
Why
| reCAPTCHA v2 (measured 2026-10-20) | SEO Captcha | |
|---|---|---|
| Requests / hosts | 7 requests to 3 Google domains | 1 request to your server |
| Download | ~428 KB | ~14 KB gzipped |
| Cookies, tracking | Google's | none |
| Puzzles for people | image challenges | none (or one checkbox) |
| Account / API key / quota | Google account, keys | none |
What it is not: proof-of-work raises the cost of automated submissions and blocks the large majority of form bots, but it does not score behaviour like reCAPTCHA Enterprise, and a determined attacker with server capacity can still solve puzzles. Adaptive difficulty makes that expensive at scale. See docs/security.md.
Quick start (PHP)
composer require seoultra/seo-captcha
1. Challenge endpoint — captcha.php:
<?php require __DIR__ . '/vendor/autoload.php'; use SeoUltra\SeoCaptcha\SeoCaptcha; use SeoUltra\SeoCaptcha\Http\ChallengeEndpoint; // SEO_CAPTCHA_SECRET = 64 random hex chars: php -r 'echo bin2hex(random_bytes(32));' $captcha = SeoCaptcha::create((string) getenv('SEO_CAPTCHA_SECRET'), '/var/lib/seo-captcha'); (new ChallengeEndpoint($captcha))->handle($_SERVER, $_GET)->send();
2. The form — serve vendor/seoultra/seo-captcha/dist/seo-captcha.js from your public
directory:
<script src="/js/seo-captcha.js" defer></script> <form method="post" action="/contact.php"> <input name="email" type="email" required> <textarea name="message"></textarea> <div class="seo-captcha" data-challenge-url="/captcha.php" data-scope="contact"></div> <button>Send</button> </form>
3. Verify on submit — contact.php:
$result = $captcha->verifyRequest($_POST, 'contact', $_SERVER['REMOTE_ADDR'] ?? null); if (!$result->ok) { http_response_code(400); exit('Please try again.'); // log $result->reason, don't show it }
SeoCaptcha::create() uses a file store (single server). For several web servers share a
PdoStore (MySQL/PostgreSQL/SQLite) or RedisStore — see docs/integration.md.
No Composer? bin/export-library.sh <dir> and require '<dir>/autoload.php'.
The widget
| Attribute | Default | |
|---|---|---|
data-challenge-url |
— (required) | your endpoint |
data-scope |
default |
one per form (contact, login, …); tokens only work for their scope |
data-mode |
auto |
auto (solves when the visitor touches the form), click ("I'm not a robot" checkbox), invisible (badge only) |
data-theme |
light |
light, dark, auto |
data-lang |
page <html lang> |
125 languages; English and Indonesian are built in, others come from your endpoint |
data-worker-url |
— | worker file for CSPs without worker-src blob: |
If the visitor submits before the puzzle is solved, the widget holds the submit, solves,
then submits — with the original submit button. JavaScript API: SeoCaptcha.execute(form)
→ Promise<token>, getResponse(), reset(), events seocaptcha:verified|error|expired.
Full reference: docs/integration.md.
Status
| PHP library (challenge, verify, stores: file, PDO, Redis, memory), endpoint, widget helper | ✅ PHPUnit + PHPStan level 9 |
| Widget: auto/click/invisible, Web Workers + main-thread fallback, strict CSP, a11y, RTL, dark | ✅ browser tests (Chromium) |
| 125 widget languages | ✅ machine-assisted, native review welcome |
| WordPress plugin, other server languages (Node, Python, Go) | ❌ not yet — the protocol is documented for ports |
Development
PHP ≥ 8.1 + Composer; browser tests need Python 3 + Playwright (Chromium). No Node.js.
composer install php bin/build.php # build dist/ from js/src composer check # build freshness, PHPUnit, PHPStan python3 tests/run_browser_tests.py # JS unit + end-to-end tests in headless Chromium
Docs: protocol · integration · security · contributing · security policy · changelog