Skip to content

[6.x] Forms 2: Add captcha support to forms - #15862

Open
DanielDarrenJones wants to merge 4 commits into
statamic:forms-2from
DanielDarrenJones:feature/forms-2-captcha
Open

DanielDarrenJones wants to merge 4 commits into
statamic:forms-2from
DanielDarrenJones:feature/forms-2-captcha

Conversation

@DanielDarrenJones

Copy link
Copy Markdown
Contributor

Statamic forms have no captcha support. The only built-in spam protection is the honeypot, which stops simple bots but not ones that leave hidden fields empty or run a real browser. So people end up using addons or wiring up Turnstile, hCaptcha or reCAPTCHA themselves: rendering the widget, then listening to FormSubmitted to check the token. Each addon ends up doing this its own way, and none of them can use forms-2's spam status the way the honeypot does.

This PR adds captchas to core, driver based, so one is set up once in config and turned on per form. The same driver API can be used by addons that need a captcha on their own forms.

Setting it up

// config/statamic/forms.php
'captcha' => [
    'driver' => env('STATAMIC_CAPTCHA_DRIVER', 'turnstile'),
    'site_key' => env('STATAMIC_CAPTCHA_SITE_KEY'),
    'secret_key' => env('STATAMIC_CAPTCHA_SECRET_KEY'),
    'score' => 0.5,       // reCAPTCHA v3 only
    'action' => 'submit', // reCAPTCHA v3 only
    'timeout' => 5,
],

Built-in drivers are turnstile, hcaptcha, recaptcha (v2 checkbox), recaptcha_v3 and null. Nothing happens until both keys are set.

Each form then gets two new settings next to the honeypot ones:

  • Captcha: on or off (off by default, so existing forms don't change).
  • Captcha Behavior: shown when the form stores submissions. Show an Error refuses the submission with a validation error under captcha. Save as Spam stores it as spam for review, the same way the honeypot's "Save as Spam" does. A form that doesn't store submissions always shows an error.

Rendering

The form tag puts the widget at the end of the form automatically. To place it yourself, use {{ captcha }} (or {!! $captcha !!} in Blade components), or {{ form:captcha }} inside the form or {{ form:captcha in="contact" }} outside it. The tag sees the widget's data-captcha attribute and doesn't add it a second time.

On multi-page forms the widget only appears on the last page, since that's where it's checked.

Verification

SubmitForm::submit() checks the captcha when the submission is about to be finalized, after field validation and the honeypot and before FormSubmitted is dispatched. Earlier pages of a multi-page form and precognitive validation requests are never checked.

Verification uses Laravel's HTTP client with a timeout. A provider that can't be reached, an error response, or anything other than "success": true counts as a failure. Missing tokens, tokens that aren't strings, and tokens over 8192 characters fail without making a request. reCAPTCHA v3 also checks the score and that the token was made for the configured action. The token is never stored on the submission.

If a resumed partial submission fails on its last page, the partial is kept so the visitor can try again.

Custom drivers

use Statamic\Facades\Captcha;

Captcha::extend('friendly', fn ($app, array $config) => new FriendlyCaptcha($config));

A driver implements Statamic\Contracts\Forms\Captcha:

public function widget(): string;                                   // HTML for inside the form, with data-captcha on it
public function verify(?string $token, ?string $ip = null): bool;   // false on failure or when unreachable
public function field(): string;                                    // the input the widget fills

Providers that use a "siteverify" endpoint can extend Statamic\Forms\Captcha\SiteverifyCaptcha and only supply the endpoint, field name and widget. Custom drivers count as set up as soon as they're the configured driver, since they may not use a site key and secret.

Addons can use the same thing for their own forms: Captcha::enabled(), Captcha::driver()->widget(), and Captcha::driver()->verify($token, $request->ip()).

Tests

tests/Forms/Captcha/* (manager, drivers via Http::fake, SubmitForm), tests/Tags/Form/FormCaptchaTest.php, and a CP update test in tests/Feature/Forms/UpdateFormTest.php.

Docs

Docs PR to follow for statamic.dev (forms → spam protection), covering the config, the per-form settings, the tag/variable, and writing a driver.

Open questions

  1. Namespace and config location. Everything lives under Forms and config/forms.php. If user registration, password reset or addons should use it too, Statamic\Captcha and its own config/captcha.php (or system.php) may fit better. Right now the user registration form only has a honeypot.
  2. Default per form. Captchas are opt-in per form, so nothing changes for existing forms or AJAX setups. Should they be on for every form once the keys are set, with a per-form opt-out instead?
  3. One driver or named configs. There's one driver and one set of keys, like Statamic's simpler configs. The Manager pattern supports named configurations (like protect.schemes) if different forms should use different providers or keys. Is that worth the extra config?
  4. Error key. Failures go under captcha, not the provider's field (cf-turnstile-response), so templates can use {{ error:captcha }} with any driver. This clashes if a blueprint has a field with the handle captcha. Reserve the handle, or use something like _captcha?
  5. Precognition / AlpinePrecognition. $form.submit() sends only the Alpine data, not the widget's hidden input. Something has to copy the token into the form data (for example a Turnstile data-callback). Should the Alpine Precognition driver do that itself, or should it be documented?
  6. reCAPTCHA v3 action per form. The action is global (submit). Using the form handle would stop a token made for one form being replayed on another, but that would need the form passed to widget()/verify(), which makes the contract bigger. Worth it?
  7. Tag name clash. {{ form:captcha }} stops a form with the handle captcha being used as {{ form:captcha }}. fields, errors, success and the other tags already have the same problem, but it's one more.
  8. CSP / script loading. Widgets load the provider's script inline with async defer. Sites with a strict CSP need the provider's domains allowed. Should there be a nonce hook, or a way to output the script separately (for example {{ form:captcha_script }})?
  9. Failing closed. A provider outage blocks every captcha form (or sends everything to spam with "Save as Spam"). That's the safe default. Should there be a fail_open option?
  10. Rate of verification calls. Bots can make the server call the provider once per post that has a token. The 8 KB limit and the timeout cap the cost, but there's no throttling. Leave that to the existing route throttling?

Reviewed with sol-loop: 2 rounds with gpt-5.6-sol on high. Sol verdict: approved

Created with Claude Opus 5.5 in Claude Code.

DanielDarrenJones and others added 4 commits November 11, 2026 22:29
Adds a CaptchaManager with turnstile, hcaptcha, recaptcha (v2) and
recaptcha_v3 drivers, configured in config/forms.php and extendable with
Captcha::extend(). Forms opt in from their settings, and failures are
either refused with a validation error or saved as spam, mirroring the
honeypot. The form tag renders the widget automatically on the last page,
or where {{ captcha }} / {{ form:captcha }} places it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants