Skip to content

Protect the website forms with Cloudflare Turnstile - #117

Merged
sistrall merged 4 commits into
mainfrom
cd16/ft/turnstile-on-forms
Sep 7, 2026
Merged

sistrall merged 4 commits into
mainfrom
cd16/ft/turnstile-on-forms

Conversation

@sistrall

@sistrall sistrall commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Why

After moving the dashboard signup to Cloudflare Turnstile (Basecamp card 10185982929), the sales/contact form on the website is next. While looking at it: every website form ran invisible reCAPTCHA v3, but the server only ever checked success and ignored the v3 score, so any bot driving a real browser got through. The OpenAI spam classifier was doing all the real work.

What

Every form on the site now runs Turnstile, and reCAPTCHA is gone.

  • Shared React form (FormReactComponent: contact, support, partner program, Slack invite, lp/migcs, lp/nuxt2eol, lp/modernize-cms): a useTurnstile hook renders an interaction-only widget with execution: 'execute', so nothing is visible unless Cloudflare wants a click, and a fresh single-use token is solved at submit time like before.
  • Newsletter footer and docs feedback (web components): a solveTurnstile(container, action) helper does the same on submit.
  • Server (src/lib/turnstile.ts): verifies against siteverify and checks the action label matches the form. No secret configured → check disabled for that environment. Cloudflare unreachable or internal-error → fail open and log to Rollbar, mirroring VerifiesCaptcha in the API. Cloudflare's testing keys never echo action, so that check is skipped when the response is flagged as a testing-key result.
  • Per-form labels (contact, support, partner-program, slack-invite, lp-*, newsletter, docs-feedback) so the Turnstile analytics tell the forms apart.
  • Env wiring: TURNSTILE_SITE_KEY is a build-time public var (astro.config, Dockerfile, cubo.json5 build args), TURNSTILE_SECRET_KEY a runtime secret via cubo config:set. Both optional. In development the code falls back to Cloudflare's always-pass test keys, so no local setup is needed.
  • Removed: src/lib/recaptcha.ts, src/lib/loadRecaptcha.ts, react-recaptcha-hook, and the RECAPTCHA_KEY / RECAPTCHA_SECRET_KEY wiring.

Before deploying

  1. Create a Turnstile widget in Cloudflare for www.datocms.com and the staging hostnames (separate from the dashboard widget).
  2. Fill the empty TURNSTILE_SITE_KEY slots in cubo.json5.
  3. cubo config:set TURNSTILE_SECRET_KEY=<value> --env <env> per environment. Until the secret is set, verification is skipped there, so staging can go first.

Verified

  • astro check passes.
  • Against the dev server: empty and oversized tokens get a 401 from the actions before any Pipedrive/Front call; the client module resolves the test sitekey.
  • Not yet exercised in a real browser session. Please submit the contact form once on staging before production.

Left alone on purpose

  • src/documents/gdpr.md and privacy.json still list Google reCAPTCHA as a sub-processor; they should mention Cloudflare Turnstile instead.
  • pipedrive.createSalesLead and pipedrive.createPartnerProgramLead accept a token they never verify and have no callers in this repo.

https://claude.ai/code/session_01QhGrJ2y278ixVwiwZdSaEi

The contact, support, partner-program, Slack invite and landing-page forms
all go through `FormReactComponent`, which used to run invisible reCAPTCHA
v3 on submit. The server only ever checked `success`, never the v3 score,
so any bot driving a real browser passed. This swaps the widget for
Cloudflare Turnstile, the same supplier the dashboard signup moved to.

- `~/lib/loadTurnstile` loads api.js lazily with `render=explicit`; falls
  back to Cloudflare's always-pass test sitekey in development.
- `useTurnstile` renders an `interaction-only` widget with `execution:
  'execute'`, so nothing is visible unless Cloudflare wants a click, and a
  fresh single-use token is solved at submit time like before.
- `~/lib/turnstile` verifies against siteverify and checks the `action`
  label matches the form. No secret → check disabled for that environment;
  Cloudflare unreachable or `internal-error` → fail open and log, mirroring
  `VerifiesCaptcha` in the API.
- Each form carries its own `turnstileAction` (contact, support, ...) so
  the Turnstile analytics tell them apart.
- `TURNSTILE_SITE_KEY` is a build-time public var, `TURNSTILE_SECRET_KEY`
  a runtime secret via `cubo config:set`. The sitekey slots in cubo.json5
  are left empty until the widget is created in Cloudflare.

The newsletter and docs-feedback forms still use reCAPTCHA; they move in
the next commit, together with the reCAPTCHA removal.

Claude-Session: https://claude.ai/code/session_01QhGrJ2y278ixVwiwZdSaEi
The two web-component forms get a `solveTurnstile(container, action)`
helper that renders an interaction-only widget on submit and resolves
with the token, the same shape the old `loadRecaptcha().execute()` had.

With every form on Turnstile, reCAPTCHA goes away: the two `~/lib`
modules, the `react-recaptcha-hook` dependency, and the `RECAPTCHA_KEY` /
`RECAPTCHA_SECRET_KEY` wiring in astro.config, Dockerfile, cubo.json5 and
.env.example.

Left alone on purpose: the `pipedrive.createSalesLead` and
`pipedrive.createPartnerProgramLead` actions still accept a `token` they
never verify (no callers in this repo), and the GDPR / privacy documents
still list Google reCAPTCHA as a sub-processor.

Claude-Session: https://claude.ai/code/session_01QhGrJ2y278ixVwiwZdSaEi
Cloudflare's client-side rendering docs call `turnstile.execute` with the
container while `reset`/`remove`/`getResponse` take the widget ID. Follow
the documented shape instead of assuming the ID works for both.

Claude-Session: https://claude.ai/code/session_01QhGrJ2y278ixVwiwZdSaEi
@sistrall sistrall self-assigned this Sep 4, 2026
@sistrall
sistrall force-pushed the cd16/ft/turnstile-on-forms branch from 355d557 to 0159925 Compare September 7, 2026 09:13
@sistrall
sistrall merged commit 0adbeea into main Sep 7, 2026
3 checks passed
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.

1 participant