Search by

seoultra / seo-captcha

seoultra

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.

v0.1.0 2026-10-20 07:40 UTC

This package is auto-updated.

Last update: 2026-10-25 18:27:27 UTC


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.

 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