PHPackages                             willybahuaud/gaitcha - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Security](/categories/security)
4. /
5. willybahuaud/gaitcha

ActiveLibrary[Security](/categories/security)

willybahuaud/gaitcha
====================

Frictionless, self-hosted CAPTCHA combining behavioral analysis, random field names, and HMAC tokens.

v0.8.0(1mo ago)22312GPL-2.0-or-laterPHPPHP &gt;=7.4

Since Mar 7Pushed 4mo agoCompare

[ Source](https://github.com/willybahuaud/gaitcha)[ Packagist](https://packagist.org/packages/willybahuaud/gaitcha)[ RSS](/packages/willybahuaud-gaitcha/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (2)Dependencies (2)Versions (12)Used By (2)

Gaitcha
=======

[](#gaitcha)

Self-hosted behavioral captcha. A simple checkbox analyzes how the user interacts with it — mouse trajectory, keyboard timing, touch gestures — to tell humans from bots. No third-party dependency, no tracking, no friction.

Why
---

[](#why)

Most captcha solutions either rely on third-party services (sending user data to external servers) or use proof-of-work alone — which automated browsers solve without breaking a sweat.

Gaitcha takes a different approach: it watches **how** the user reaches and checks a visible checkbox. Humans hesitate, deviate, decelerate, click slightly off-center. Bots click perfectly, instantly, without inertia. The behavioral log is scored server-side — no external API, no user fingerprinting, fully stateless.

An optional [proof-of-work layer](#proof-of-work) sits in front: the client must burn CPU before the server even hands out the captcha field. Behavioral analysis catches the fakes; proof of work makes faking expensive at scale.

Quick Start
-----------

[](#quick-start)

### Install

[](#install)

```
composer require willybahuaud/gaitcha
```

Then build the JS client:

```
npm install && npm run build
```

This generates `dist/gaitcha.min.js` — copy it to your public assets directory and include it in your HTML.

### HTML

[](#html)

```

    Send

```

### PHP — Init endpoint

[](#php--init-endpoint)

```
use Gaitcha\Config;
use Gaitcha\AbstractEndpoint;

$config = new Config([
    'secret' => 'your-secret-key-at-least-32-characters',
]);

class CaptchaEndpoint extends AbstractEndpoint
{
    protected function sendJsonResponse(array $data): void
    {
        header('Content-Type: application/json');
        echo json_encode($data);
    }

    public function handle(): void
    {
        // Pass the decoded JSON body — required when 'pow' is enabled,
        // harmless otherwise.
        $request = json_decode((string) file_get_contents('php://input'), true);

        $this->sendJsonResponse($this->handleInit(is_array($request) ? $request : []));
    }
}

$endpoint = new CaptchaEndpoint($config);
$endpoint->handle();
```

### PHP — Validation

[](#php--validation)

```
use Gaitcha\Config;
use Gaitcha\ValidationOrchestrator;

$config       = new Config(['secret' => 'your-secret-key-at-least-32-characters']);
$orchestrator = new ValidationOrchestrator($config);
$result       = $orchestrator->validate($_POST);

if ($result->isAccepted()) {
    // Process the form.
} else {
    // $result->getReason():
    // token_absent | token_invalid | token_expired
    // token_already_used | score_insufficient | log_malformed
}
```

### Manual JS init

[](#manual-js-init)

```
const instance = Gaitcha.init(document.querySelector('#my-form'), '/captcha/init', {
    label: 'I am not a robot',
    container: document.getElementById('captcha-slot'), // optional target element
    theme: 'auto', // 'light' (default), 'dark', or 'auto' (follows OS preference)
    style: 'minimal', // 'default' or 'minimal' (sober monochrome, no shadows)
});
```

`init()` returns an instance with `destroy()` and `reset()` (see [Widget reset](#widget-reset) below).

How It Works
------------

[](#how-it-works)

1. The form loads with a **placeholder widget** — same dimensions as the final captcha (zero layout shift), visibly dimmed, with no field name and no token. It announces the captcha without exposing anything
2. On the first interaction signal (mousemove, touchstart, focus, keydown), the client contacts the init endpoint. If proof of work is enabled, the server answers with a challenge that a Web Worker solves in the background (a spinner shows in the placeholder meanwhile)
3. The init endpoint then delivers a signed token and a random field name; the placeholder is upgraded in place into the real, interactive widget
4. The JS collects interaction events: mouse moves, touch moves (with pressure and contact radius when available), keyboard tabs, and timing data
5. When the user checks the widget, the behavioral log is serialized immediately — ready for both classic form submits and AJAX-based plugins
6. The server verifies the token (signature + TTL) and scores the behavior across multiple signals

The scoring engine detects three profiles and uses the one that matches the check event:

- **Mouse** — trajectory shape, non-linearity, speed variation, angular jitter, direction reversals, endpoint deceleration, click offset, anti-Bezier signals, anti-CDP signals (coalesced events average, screen coordinate delta)
- **Keyboard** — focus-to-key timing, dwell time variance, navigation pattern (Tab/Shift+Tab)
- **Touch** — same trajectory signals as mouse, plus touch-specific data: pressure variance across the swipe, contact radius variance, and tap gesture analysis (duration, force, radiusX/Y on the final tap)

Multiple "kill signals" cause immediate rejection: interaction under 100ms, no movement before click, pixel-perfect center click/tap. If the primary profile doesn't kill, a secondary profile is scored when data exists — the highest score wins (benefit of the doubt for the human).

Widget
------

[](#widget)

The widget is a self-contained UI component: custom checkbox with animated states (pending, idle, loading with spinner, checked with bounce), a "gaitcha" badge, and hidden inputs for the token and behavioral log. All styles are injected via a single `` tag — no external CSS file needed.

A placeholder version is injected as soon as the page loads: same dimensions as the active widget, dimmed checkbox and label, non-interactive, and **empty** — no field name, no token, nothing for a bot to scrape. When the user first interacts with the form, it upgrades in place. No layout shift, and the user knows from the start that a verification step exists.

### Theming

[](#theming)

Two orthogonal axes: **theme** (ambiance) and **style** (visual language). Both available as `Gaitcha.init()` options or as HTML attributes (`data-gaitcha-theme`, `data-gaitcha-style`).

**Theme:**

ValueBehavior`'light'`Light background (default)`'dark'`Dark background, forced`'auto'`Follows OS preference via `prefers-color-scheme`**Style:**

ValueBehavior`'default'`Soft radii, subtle shadows, blue accent, green success state`'minimal'`Sober monochrome: 2px radii, 1px hairline borders, no shadows, no ripple, ink-on-paper checked state. Designed to blend into editorial and high-end sitesThe two combine freely — `minimal` + `dark` gives a deep-ink widget with an inverted (white box, dark check) checked state.

All CSS variables are scoped to `.gaitcha-widget` (no `:root` pollution). Every property uses `!important` to survive third-party form plugin CSS that tends to override everything.

### Responsive layout

[](#responsive-layout)

The widget is fluid (`width: 100%`, `max-width: 260px`). On narrow containers, a CSS container query on the content area switches the badge to compact mode — the brand name collapses to a "g" overlay on the shield icon. No media queries, so it adapts to the actual available space regardless of viewport size.

### Widget reset

[](#widget-reset)

After a server-side rejection on AJAX forms, the widget needs to go back to an unchecked state so the user can retry. Two ways to do it:

```
// Via the instance returned by init()
const instance = Gaitcha.init(form, endpoint, options);
// ... after rejection:
instance.reset();

// Or via the static API
Gaitcha.reset(form);
```

`reset()` unchecks the widget, clears the behavioral log, and fetches a fresh token from the server. The user gets a clean slate for a new attempt.

Configuration
-------------

[](#configuration)

OptionTypeDefaultDescription`secret`string**required**HMAC secret key (min 32 characters)`ttl`int`120`Token validity duration (seconds)`score_threshold`float`0.5`Minimum behavioral score (0.0–1.0)`debug`bool`false`Include scoring details in the response`no_js_fallback`string`'reject'``'reject'` or `'allow'` when JS is disabled`token_field_name`string`'_ct'`Hidden field name for the signed token`field_prefix`string`'_gc_'`Prefix for generated field names`anti_replay`bool`false`Reject reused tokens (requires a `token_store`)`token_store`TokenStoreInterface`null`Storage backend for anti-replay`pow`bool`false`Require a proof of work before issuing tokens`pow_difficulty`int`18`Leading zero bits required (8–26)`pow_challenge_ttl`int`90`Challenge validity duration (seconds)### Proof of work

[](#proof-of-work)

Without it, `/captcha/init` is free: a bot can harvest the random field name and a valid token at will, then forge a plausible behavioral log. The PoW layer changes the economics — every token costs CPU time first.

```
$config = new Config([
    'secret' => 'your-secret-key-at-least-32-characters',
    'pow'    => true,
]);
```

How it flows, on a single endpoint:

1. First call to `/captcha/init` → the server answers `{ pow_challenge: { nonce, difficulty, expires, signature } }` instead of a token. The challenge is HMAC-signed and stateless
2. The client must find a counter such that `sha256(nonce + '.' + counter)` starts with `difficulty` zero bits. This runs in a Web Worker (with a chunked main-thread fallback if workers are blocked by CSP) — the user never notices, it overlaps with their first interaction
3. The client calls `/captcha/init` again with `{ pow: { ...challenge, solution } }` → the server verifies the signature, expiry and solution, then issues the token

At the default difficulty of 18 bits, solving takes roughly 100–500 ms on desktop, up to a couple of seconds on low-end mobile. Each behavioral check costs a human ~0 (background work), but harvesting 10,000 tokens costs a bot real CPU time. Raise `pow_difficulty` if you're under attack — each +1 doubles the cost.

Two things to know:

- **Your endpoint must pass the decoded JSON request body to `handleInit()`** (see the endpoint example above). With `pow` enabled, `handleInit()` throws a `LogicException` if you don't — better than failing silently
- **Pair it with `anti_replay`** to consume each challenge nonce on first use (one solution = one token). Without a `token_store`, a solved challenge stays reusable until it expires (`pow_challenge_ttl`)

### Anti-replay

[](#anti-replay)

```
use Gaitcha\Config;
use Gaitcha\FileTokenStore;

$config = new Config([
    'secret'       => 'your-secret-key-at-least-32-characters',
    'anti_replay'  => true,
    'token_store'  => new FileTokenStore('/tmp/gaitcha-tokens.json'),
]);
```

`FileTokenStore` works for moderate traffic. For high-traffic sites, implement `TokenStoreInterface` with Redis or your database — the `checkAndAdd()` method must be atomic (e.g. `SETNX` for Redis, `INSERT ... ON CONFLICT` for SQL).

### HTML attributes

[](#html-attributes)

AttributeDescription`data-gaitcha`Enables Gaitcha on the form`data-gaitcha-endpoint`Init endpoint URL (default: `/captcha/init`)`data-gaitcha-label`Checkbox label (default: "Je ne suis pas un robot")`data-gaitcha-container`ID of a DOM element where the checkbox should be injected`data-gaitcha-theme`Widget theme: `light` (default), `dark`, or `auto``data-gaitcha-style`Widget style: `default` or `minimal`Limits
------

[](#limits)

- Not bulletproof against targeted attacks with headed browsers and behavioral simulation — but that level of effort is better addressed by rate limiting
- Requires JavaScript (configurable fallback for no-JS users)
- Designed to stop mass spam, not to protect high-value targets

Development
-----------

[](#development)

```
composer install && npm install

# PHP tests
composer test

# Build JS (→ dist/gaitcha.min.js)
npm run build

# Demo (watch + PHP server)
npm run dev &
npm run serve
# → http://localhost:8080
```

WordPress plugin
----------------

[](#wordpress-plugin)

Using WordPress? Check out [Gaitcha for WordPress](https://github.com/willybahuaud/gaitcha-for-wp) — a ready-made plugin with connectors for CF7, Gravity Forms, WPForms, Fluent Forms, Formidable, Ninja Forms, WS Form, Elementor Pro, and native WordPress forms (login, register, lost password, comments).

Author
------

[](#author)

[Willy Bahuaud](https://wabeo.fr) — WordPress Architect

License
-------

[](#license)

GPL-2.0-or-later

###  Health Score

35

—

LowBetter than 77% of packages

Maintenance80

Actively maintained with recent releases

Popularity13

Limited adoption so far

Community13

Small or concentrated contributor base

Maturity31

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~12 days

Recently: every ~23 days

Total

9

Last Release

58d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/0e8a7bedebdd7f13546cf2503217cced5340f74c2b64ec00e47e32b16412bb95?d=identicon)[willybahuaud](/maintainers/willybahuaud)

---

Top Contributors

[![willybahuaud](https://avatars.githubusercontent.com/u/1868803?v=4)](https://github.com/willybahuaud "willybahuaud (22 commits)")

---

Tags

anti-spambehavioral-analysisbot-detectioncaptchaphpself-hostedspam-protectionwordpress

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/willybahuaud-gaitcha/health.svg)

```
[![Health](https://phpackages.com/badges/willybahuaud-gaitcha/health.svg)](https://phpackages.com/packages/willybahuaud-gaitcha)
```

###  Alternatives

[paragonie/ecc

PHP Elliptic Curve Cryptography library

25866.3k42](/packages/paragonie-ecc)[sansec/magento2-module-shield

15218.6k](/packages/sansec-magento2-module-shield)[dwgebler/encryption

Encryption wrapper for PHP using libsodium — simple API for symmetric and asymmetric encryption, password hashing, digital signing, and message authentication.

317.5k](/packages/dwgebler-encryption)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
