How do I stop bot signups on a Rails sign-up form?
Set TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY. app/adapters/bot_check.rb returns a real Cloudflare Turnstile verifier when both are present and a pass-everything fake when they are not, so the form works in development with no account and starts blocking bots in production without a code change.
Turn it on
Create a Turnstile widget in the Cloudflare dashboard, which is free, and copy the two keys. The site key is public and renders in the page; the secret key verifies the token server side.
bin/kamal app exec 'bin/rails runner "puts BotCheck.enabled?"'
Why it is an adapter rather than a gem call
Same reason as billing. app/adapters/bot_check.rb returns a verifier when the keys are set and a
fake when they are not, so:
- A fresh clone has a working sign-up form with no Cloudflare account.
- The test suite does not make network calls or depend on a third party being up.
- Turning it on is configuration, not a deploy of new code paths.
If you would rather use a different provider, the add-integration skill builds the same shape for
it, and the calling code does not change.
Add Cloudflare's host to the policy
The widget loads a script from Cloudflare, so script_src in
config/initializers/content_security_policy.rb needs its host. The policy is enforced, so without
it the widget silently fails to render and the form submits without a token, which then fails
verification. Two silent failures stacked.
What this does and does not stop
It stops automated scripted signups, which is the volume problem. It does not stop a determined human, and it does not stop someone using a real email address to create throwaway accounts.
The other half of the defense is already there: Rack::Attack throttles by IP, configured in
config/initializers/rack_attack.rb. Note the public content paths are deliberately exempt so search
and AI crawlers are not rate limited, which is a different tradeoff from the sign-up form.
Test the closed path
Verification failing has to actually block the signup. Set the secret key to a deliberately wrong value locally and confirm the form rejects. An integration that fails open is worse than none, because you believe you are protected.