akibeo / kirby-altcha
ALTCHA for Kirby CMS: self-hosted, cookieless proof-of-work captcha with a challenge route, payload verification and a CSP-friendly widget.
Package info
github.com/wdebusschere/kirby-altcha
Language:JavaScript
Type:kirby-plugin
pkg:composer/akibeo/kirby-altcha
Fund package maintenance!
Requires
- php: >=8.1
- getkirby/composer-installer: ^1.2
Requires (Dev)
- altcha-org/altcha: ^2.3
- getkirby/cms: ^4.0 || ^5.0
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
- ext-sodium: Required for the ARGON2ID algorithm
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-07 08:46:46 UTC
README
ALTCHA spam protection for Kirby forms. ALTCHA is a proof-of-work captcha: the visitor's browser does a second of computing instead of the visitor solving a puzzle. It sets no cookies, tracks nobody and talks to no third party, so it needs no cookie banner and no consent gating.
- Fully self-hosted — the widget (v3), its workers and translations ship with the plugin; challenges are created and verified by your own site. No ALTCHA account, no API key, no external request.
- Widget snippet —
snippet('altcha')inside a form prints the widget and, once per page, its scripts. - Verification —
altcha()->verify()in a controller, or thealtcharule in Kirby'sinvalid(). - Replay protection — every solved challenge is accepted once and expires after 20 minutes.
- Strict CSP — no inline code, no
blob:workers; scripts carry the nonce of akibeo/kirby-csp automatically. - Multi-language — the widget follows the current Kirby language (60+ translations included).
- No dependencies — plain PHP (
hash, optionallysodium), tested against the official ALTCHA PHP library.
Installation
Composer
composer require akibeo/kirby-altcha
Download / Git submodule
Copy this repository into site/plugins/kirby-altcha/:
git submodule add https://github.com/wdebusschere/kirby-altcha.git site/plugins/kirby-altcha
No build step is required — Kirby autoloads plugins from site/plugins/. The plugin registers itself as akibeo/altcha and reads its options from the akibeo.altcha namespace.
Configuration
Add to site/config/config.php. Only secret is required; the rest are
shown with their defaults.
return [ 'akibeo.altcha' => [ 'secret' => 'a-long-random-string', // signs the challenges 'enabled' => true, // false: no widget, every verification passes // Proof of work, see "Difficulty" 'algorithm' => 'PBKDF2/SHA-256', // PBKDF2/SHA-256|384|512, SHA-256|384|512 or ARGON2ID 'cost' => 5000, // iterations per key (ARGON2ID: time cost) 'counter' => [500, 1500], // [min, max] number of keys the browser has to derive 'memoryCost' => 32768, // ARGON2ID only: memory per key in KiB 'expires' => 1200, // seconds a challenge can be solved and submitted // Widget 'route' => 'altcha/challenge', // where the widget fetches its challenge 'name' => 'altcha', // form field the widget submits its payload in 'language' => null, // widget language; defaults to the current Kirby language 'widget' => [], // default attributes of <altcha-widget>, see "Widget options" 'assetsUrl' => null, // base URL of the assets/ folder when you host it yourself; same origin only 'nonce' => null, // CSP nonce; defaults to cspNonce() when akibeo/kirby-csp is installed 'cache' => true, // remembers used challenges; false disables replay protection ], ];
Generate a secret with:
php -r "echo bin2hex(random_bytes(32)), PHP_EOL;"
Keep it in a host config (config.<host>.php) or an environment variable
that is not committed. Anyone who knows the secret can sign their own
challenges, and since challenges and signatures are public a short secret
can be brute-forced offline, so the plugin refuses secrets of fewer than
32 characters and the placeholder above. Without a valid secret the
challenge route answers with HTTP 500 (the reason is in the PHP error log,
and in the response while Kirby's debug option is on) and
altcha()->verify() throws an Akibeo\Altcha\AltchaException: a
misconfigured site should not look like a visitor failing the captcha.
Replay protection
A solved challenge is accepted once: verify() remembers its signature in
the akibeo.altcha cache (site/cache/<host>/akibeo.altcha) until the
challenge expires. With 'cache' => false nothing is remembered and one
solved challenge can be submitted again and again until it expires, so
only switch the cache off when every submission is checked some other way.
The check is a read followed by a write, not a single atomic operation, so
a burst of simultaneous submissions of the same payload can slip through
as duplicates; it cannot be used to skip the proof of work.
Per-environment overrides
config.localhost.php and friends can override any key:
'akibeo.altcha' => [ 'enabled' => false, // no captcha while developing or in end-to-end tests ],
Difficulty
The browser derives one key per counter value until it reaches the secret
counter the server picked from the counter range; each key costs cost
iterations. A desktop browser derives roughly 1000 PBKDF2/SHA-256 keys of
cost 5000 per second, a phone a few hundred, so the defaults take about a
second on a desktop and a few seconds on a phone. Raise counter to make
bots work harder, lower it when visitors have to wait. ALTCHA itself
recommends a
counter of 5000 to 10000 at this cost, which is ten times the default here;
try it on a phone before going that high.
ARGON2ID is memory-hard, which takes away most of the advantage of GPUs.
It needs the sodium PHP extension and much smaller numbers, for example:
'algorithm' => 'ARGON2ID', 'cost' => 2, 'memoryCost' => 32768, // 32 MiB 'counter' => [20, 40],
Creating a challenge costs the server one key derivation, and the
challenge route is open to everyone: with the PBKDF2 defaults that is a few
milliseconds per request, with ARGON2ID it is memoryCost of RAM (32 MiB
above) per request, which makes the route a cheap way to load the server.
If you use ARGON2ID on a public site, rate-limit /altcha/challenge in
the web server or a reverse proxy.
Widget options
widget holds default attributes
for every <altcha-widget>; an array is JSON-encoded, which is how the
configuration attribute takes the options that have no attribute of
their own:
'widget' => [ 'auto' => 'onsubmit', // off, onfocus, onload, onsubmit 'display' => 'standard', // standard, bar, floating, overlay, invisible 'configuration' => ['hideFooter' => true, 'hideLogo' => true], ],
The look is set with CSS custom properties
(--altcha-border-radius, --altcha-color-base, …) in your own stylesheet.
Content-Security-Policy
The plugin loads the widget's stylesheet, script and workers as plain files
from your own domain (/media/plugins/akibeo/altcha/…), and the challenge
from /altcha/challenge. A policy of script-src 'self' (or a nonce),
style-src 'self', worker-src 'self' and connect-src 'self' is enough;
no 'unsafe-inline', blob: or external host is needed.
With akibeo/kirby-csp the tags
carry the per-request nonce. Without it, set 'nonce' => fn () => myNonce()
(a callable is resolved per request) or leave it null.
Usage
Widget
Inside a <form>, in a plain PHP template:
<form method="post" action="<?= $page->url() ?>"> … <?php snippet('altcha') ?> <button type="submit">Send</button> </form>
In a Blade layout:
@snippet('altcha')
Attributes for one widget:
<?php snippet('altcha', ['attrs' => ['auto' => 'onsubmit', 'display' => 'floating']]) ?>
The snippet prints the stylesheet and scripts in front of the first widget
of a page. To put them in the <head> instead, print the parts yourself:
<?= altcha()->scriptTag() ?> <!-- in the <head> --> <?= altcha()->widget() ?> <!-- in the form -->
The widget adds a hidden altcha field to the form once the challenge is
solved. It fetches the challenge with JavaScript, so the page itself can be
served from the pages cache.
Verification
In a controller:
return function ($kirby) { if ($kirby->request()->is('POST')) { if (altcha()->verify() === false) { $alert = ['altcha' => t('akibeo.altcha.invalid')]; } else { // send the email … } } return ['alert' => $alert ?? null]; };
verify() reads the altcha field of the current request; pass the payload
yourself when it comes from somewhere else (altcha()->verify($data['altcha'])).
After a failure altcha()->error() tells why: missing, malformed,
signature, expired, solution or replay.
With Kirby's invalid() helper, as a rule next to the other fields. Add
required, or an empty field is not checked at all:
$invalid = invalid($data, [ 'email' => ['required', 'email'], 'altcha' => ['required', 'altcha'], ], [ 'email' => 'Please enter a valid email address', 'altcha' => t('akibeo.altcha.invalid'), ]);
akibeo.altcha.invalid is translated in English, Dutch, French, German,
Portuguese and Spanish.
A payload is accepted once: verify it at the point where the form is otherwise valid and about to be processed, not before. When the form is shown again after a server-side error, the widget starts over with a new challenge.
JSON endpoints
For a form submitted with fetch(), send the field along (it is part of
new FormData(form)) and call altcha()->verify() in the route. Reset the
widget after a failed submission so it fetches a new challenge:
document.querySelector('altcha-widget').reset();
Challenge endpoint
GET /altcha/challenge returns a new challenge, never cached:
{
"parameters": {
"algorithm": "PBKDF2/SHA-256",
"cost": 5000,
"expiresAt": 1791203960,
"keyLength": 32,
"nonce": "bd691f089793ea78f3861ff7170447ff",
"salt": "8c41f119481bc35d37706077a460ff3c",
"keyPrefix": "ae3f3c5e635e2c8827b51b63bd419392"
},
"signature": "045af8ec…"
}
Updating the widget
assets/ holds the files of the altcha
npm package (currently 3.3.0): dist/external/altcha.min.js and
altcha.css, dist/workers/{pbkdf2,sha,argon2id}.js and the per-language
files of dist/i18n/. assets/altcha.init.js is the plugin's own and
registers the workers.
Development
composer install composer test # vendor/bin/phpunit
The logic lives in Akibeo\Altcha\Altcha (src/Altcha.php). The test suite
in tests/ runs it against a bare Kirby app and against the official
altcha-org/altcha library,
which solves the plugin's challenges and creates challenges for the plugin
to verify.
License
MIT — © E-xperience LAB
The bundled ALTCHA widget is MIT licensed, © Daniel Regeci, BAU Software
s.r.o. (assets/LICENSE-altcha.txt).
Credits
- Wannes Debusschere
- ALTCHA by Daniel Regeci