ALTCHA for Gravity Forms
Invisible ALTCHA spam protection for Gravity Forms — MIT, no external API, no license keys
by Genero · github.com/generoi/gravityforms-altcha · website
Install
The author publishes release zips, so WP-CLI can install straight from GitHub:
wp plugin install https://github.com/generoi/gravityforms-altcha/releases/download/v0.6.1/gravityforms-altcha.zipReadme
ALTCHA for Gravity Forms
Invisible ALTCHA proof-of-work spam protection for Gravity Forms. No user interaction, no third-party requests, no captcha puzzles — every submission carries a signed PBKDF2 proof that the browser quietly solved in the background.
- MIT-licensed end to end (altcha-org/altcha for PHP, altcha widget for JS, this plugin for the glue).
- No external API calls — challenges are issued and verified by your own WordPress install.
- No license keys, no quotas.
- Opt-in: enable globally for all forms, or per individual form.
Requirements
- WordPress 6.0+
- PHP 8.2+
- Gravity Forms 2.5+
Installation
Composer (Bedrock-style sites)
The plugin is published through generoi/packagist.
With that repository configured in your root composer.json:
composer require generoi/gravityforms-altcha
wp plugin activate gravityforms-altcha
Manual
- Download the latest
gravityforms-altcha.zipfrom Releases. - Upload to
wp-content/plugins/(or via Plugins → Add New → Upload). - Activate.
Settings
ALTCHA is off by default. Enable it one of two ways:
- Globally — Forms → Settings → ALTCHA → toggle Enable for all forms. Every Gravity Form is then protected.
- Per form — a form's Settings → ALTCHA tab → toggle Enable ALTCHA for this form. Use this when you only want protection on selected forms.
A form is protected when the global toggle is on or that form's own toggle
is on. Both are stored in the standard Gravity Forms settings (the global one as
the gravityformsaddon_gravityforms-altcha_settings option, the per-form one in
the form meta). The genero/gravityforms_altcha/should_protect filter can still
override the saved settings programmatically.
Protection strength
Forms → Settings → ALTCHA has a Protection strength dropdown (Low /
Standard / High / Very high) controlling how hard the background proof-of-work
is — stronger costs bots more per submission but takes longer to solve on
low-end devices. Because the widget solves on page load while the form is being
filled, even the higher settings are normally invisible. Each form's ALTCHA tab
can override the site-wide strength or inherit it. For an exact custom value,
use the genero/gravityforms_altcha/cost filter.
Extra spam layers (optional)
Two independent, fully first-party layers can be toggled on under Forms →
Settings → ALTCHA. Both mark suspicious submissions as spam (via
gform_entry_is_spam) rather than rejecting them — a real visitor is never
blocked or shown an error, and a false positive stays recoverable in the entry
Spam view. They apply to every Gravity Form, independent of whether ALTCHA
itself is enabled.
- Rate limiting — flags submissions once an IP exceeds a per-form
allowance within a window (default 3 per hour). The IP is resolved independently of Gravity
Forms (sites often blank GF's stored IP for GDPR) and kept only as a salted
HMAC in a 60-second transient — the raw IP is never stored or logged. Behind
a CDN/proxy, point it at the right client-IP header (see
genero/gravityforms_altcha/client_ip_headers). - Content spam filtering — flags submissions whose text contains a definite-spam keyword (matched on word boundaries), or accumulates enough weaker signals (link farms, a URL in the name field, injected markup, wrong-script text). Scans all visitor text including composite name/address fields, and ignores zero-width characters used to evade matching.
- Email validation — unlike the two above, this blocks the field (with a
corrective message) so the visitor fixes a bad address rather than silently
never hearing back. Per-verdict checkboxes decide what to reject —
undeliverable (default on), risky (default off — may catch some real
catch-all/role addresses), and disposable (default on). Verifies via
Bouncer; requires the
BOUNCER_API_KEYenvironment variable. Fails open —unknown, a missing key, or an API error never block. Note: this sends the submitted email to a third-party service, so cover it in your privacy policy / DPA.
Logging
Each protection decision is logged through Gravity Forms' own logging framework. Enable the Gravity Forms Logging add-on, then under Forms → Settings → Logging set ALTCHA for Gravity Forms to "Log errors only" or "Log all messages" — you get a per-add-on, downloadable log file. Failures (fail / blocked / spam) log at error level, passes at debug, so "errors only" surfaces just the blocks. Sample messages:
altcha: pass {"form":43}
altcha: fail {"form":43,"reason":"replay"}
rate_limit: blocked {"form":12}
content_filter: spam {"form":7,"score":3,"signals":["keyword"]}
email_validation: blocked {"form":7,"status":"undeliverable","disposable":false,"reason":"rejected_email","domain":"example.com"}
reason for ALTCHA is missing / invalid / replay. No PII is logged —
IPs only ever appear as a salted hash, and emails as the domain only.
Every decision also fires the genero/gravityforms_altcha/log action, so you
can route records anywhere (Sentry, Query Monitor, …) independently of the GF
logger:
add_action('genero/gravityforms_altcha/log', function (string $event, string $outcome, array $context) {
// ship $event / $outcome / $context to your sink
}, 10, 3);
How it works
- Form render — when enabled for the form, the plugin injects a hidden
<altcha-widget>web component above the submit button. - Browser-side proof-of-work — the widget fetches a fresh challenge from
/wp-json/genero/gravityforms-altcha/v1/challenge(signed with a per-site HMAC secret) and brute-forces a PBKDF2/SHA-256 derived-key match. - Submission — the widget writes the solution into a hidden
altchafield that Gravity Forms posts back with the rest of the form. - Server-side verification —
gform_validationdecodes the payload, reconstructs the challenge, and runsaltcha-org/altcha::verifySolution(). On failure the submission is rejected with a generic error message. - Replay protection — each challenge is single-use. A solved payload's unique signature is remembered for the rest of its lifetime, so the same proof can't be replayed across many submissions — a bot must solve a fresh challenge every time rather than paying the cost once.
Day-to-day configuration lives in the admin UI (see Settings); the filters below cover advanced overrides.
Filters
genero/gravityforms_altcha/should_protect
Override the saved settings — force protection on (or off) for specific forms regardless of the global / per-form toggles:
add_filter('genero/gravityforms_altcha/should_protect', function (bool $protect, array $form): bool {
// Always protect the high-value lead form, whatever the toggles say.
if ((int) $form['id'] === 7) {
return true;
}
return $protect;
}, 10, 2);
genero/gravityforms_altcha/hmac_key
Use an externally-managed secret (for example a dedicated vault entry shared across a fleet of sites):
add_filter('genero/gravityforms_altcha/hmac_key', fn () => getenv('ALTCHA_HMAC_KEY') ?: null);
Returning null falls back to the auto-generated gravityforms_altcha_hmac_key
option.
genero/gravityforms_altcha/error_message
Localise or rewrite the validation error:
add_filter('genero/gravityforms_altcha/error_message', fn () => __('Spam check failed. Please reload and try again.', 'your-textdomain'));
genero/gravityforms_altcha/cost
Set an exact proof-of-work cost (PBKDF2 iterations), overriding the Protection strength dropdown. Receives the form id for context:
add_filter('genero/gravityforms_altcha/cost', fn (int $cost, ?int $formId) => 750000, 10, 2);
genero/gravityforms_altcha/client_ip_headers
Only relevant when Rate limiting is on. Ordered list of $_SERVER keys to
read the client IP from; defaults to ['REMOTE_ADDR']. Behind a CDN, prepend
the single header your CDN sets — never trust a forwarded header it doesn't,
as it can be spoofed to evade the limit or push a real visitor over it.
add_filter('genero/gravityforms_altcha/client_ip_headers', fn () => [
'HTTP_CF_CONNECTING_IP', // Cloudflare
'REMOTE_ADDR',
]);
Common headers: HTTP_CF_CONNECTING_IP (Cloudflare), HTTP_FASTLY_CLIENT_IP
(Fastly), HTTP_TRUE_CLIENT_IP (Akamai), HTTP_X_REAL_IP (nginx).
genero/gravityforms_altcha/rate_limit_max and …/rate_limit_window
Per-IP, per-form submission allowance and the window (seconds) it applies over before a submission is flagged as spam. Default: 3 per hour. For "1 per minute":
add_filter('genero/gravityforms_altcha/rate_limit_max', fn () => 1);
add_filter('genero/gravityforms_altcha/rate_limit_window', fn () => MINUTE_IN_SECONDS);
genero/gravityforms_altcha/spam_keywords and …/spam_score_threshold
Tune the content filter — definite-spam keywords (a single match flags) and the score the weaker heuristics must reach (each signal contributes 2; default 3):
add_filter('genero/gravityforms_altcha/spam_keywords', fn (array $words) => [...$words, 'crypto']);
add_filter('genero/gravityforms_altcha/spam_score_threshold', fn () => 4);
genero/gravityforms_altcha/bouncer_api_key
Provide the Bouncer key in code instead of the BOUNCER_API_KEY env var (e.g.
from a secrets manager):
add_filter('genero/gravityforms_altcha/bouncer_api_key', fn () => get_option('my_bouncer_key'));
genero/gravityforms_altcha/email_should_validate and …/email_error_message
Skip email validation for specific fields/forms, or customise the rejection message:
add_filter('genero/gravityforms_altcha/email_should_validate', fn (bool $v, $field, $form) => (int) $form['id'] !== 12, 10, 3);
add_filter('genero/gravityforms_altcha/email_error_message', fn () => __('That email looks undeliverable — please check it.', 'your-textdomain'));
Development
composer install
npm install
npm run build # outputs build/widget.js
composer lint:fix # Pint
Tests
Two layers:
composer test— theunit(pure logic) andmocked(WP functions stubbed with Brain\Monkey) suites. No WordPress, no database — runs in CI on PHP 8.2–8.4.composer test:integration— real-WordPress integration tests (wp-phpunit) covering the settings, thegform_entry_is_spamspam layers, and cost resolution against an actual Gravity Forms install. Gravity Forms is commercial, so these skip when it isn't present (e.g. CI) and run for real via wp-env or DDEV.
Run the integration suite with wp-env
(place a gravityforms checkout alongside this repo so it mounts):
npx @wordpress/env start
npx @wordpress/env run tests-cli --env-cwd=wp-content/plugins/gravityforms-altcha \
vendor/bin/phpunit -c phpunit.integration.xml.dist
License
MIT — see LICENSE.
Bundles the MIT-licensed altcha-org/altcha PHP library and the MIT-licensed altcha widget. Neither ships with this repository — both are installed through Composer and npm respectively.
Read the full README on GitHub →
Releases
| Tag | Published | Asset | Downloads |
|---|---|---|---|
| v0.6.1 | Jul 31, 2026 | gravityforms-altcha.zip | 56 |
| v0.6.0 | Jul 31, 2026 | gravityforms-altcha.zip | 4 |
| v0.5.0 | Jun 3, 2026 | gravityforms-altcha.zip | 20 |
| v0.4.0 | Jun 2, 2026 | gravityforms-altcha.zip | 5 |
| v0.3.0 | Jun 2, 2026 | gravityforms-altcha.zip | 3 |
| v0.2.0 | Jun 2, 2026 | gravityforms-altcha.zip | 4 |
| v0.1.1 | May 15, 2026 | gravityforms-altcha.zip | 3 |
| v0.1.0 | May 15, 2026 | gravityforms-altcha.zip | 2 |