Spam Protection and Turnstile

Every form Leed renders on your published site carries a Cloudflare Turnstile widget. The visitor never sees it and never solves anything: no checkbox, no puzzle, no delay. When the form is submitted, the token the widget produced is verified server-side and the result is written onto the submission as verifiedUser. Turnstile is provisioned for every workspace, on every plan, with nothing for you to configure and nothing in Settings to switch on.

What is actually on the page

The leed/form partial emits one extra element after the submit button:

<div class="cf-turnstile" data-sitekey="0x4AAA…" style="display: none;"></div>

It is hidden by an inline display: none, so it occupies no space and there is nothing to style around it. The site key in data-sitekey comes from the turnstileKey helper, which reads the key stored on your workspace’s deployment record — the public key on the live site, the preview key on a preview build.

The challenge script itself, https://challenges.cloudflare.com/turnstile/v0/api.js, is added to your <head> only when your workspace has a public site key stored. That is the normal state for a provisioned site; if you build the site locally without one, no script loads and the widget div sits inert. If you render forms yourself rather than through the partial, the turnstileKey helper is what emits the key, and you need the same hidden div for the token to exist at submit time.

What the server does with the token

The token rides along in the JSON payload as cf-turnstile-response. On the receiving side:

  1. If the request came from a preview site, it is discarded with a 204 before Turnstile is looked at. Nothing is verified and nothing is stored.
  2. Otherwise, if a Turnstile private key is configured for the site worker, the token is posted to https://challenges.cloudflare.com/turnstile/v0/siteverify. verifiedUser is set to whatever Cloudflare says.
  3. If no private key is configured, the check is skipped entirely and verifiedUser stays false.
  4. Either way, the row is written to formFillEvents and the rest of the chain — contact, opt-in, notifications — runs exactly as it would have.

Verification fails closed: a network error, a malformed response or a rejected token all resolve to false. That failure mode is the right one for the sign-in endpoints, where false means “denied”. On a form it means the submission is recorded with verifiedUser: false and nothing else changes.

One consequence worth knowing if you write callbacks: the site script’s failure branch does log Turnstile failure when it sees a 422, but /api/clerk never returns 422. Your form’s Failure Callback fires on a genuinely failed request — a form id that does not resolve, a network error — never because a visitor failed the challenge.

Where Turnstile does block

flowchart TD
  subgraph site["Published-site form — POST /api/clerk"]
    S["Visitor submits the form"] --> P{"Preview site?"}
    P -- yes --> D204["204 · fill discarded"]
    P -- no --> K{"Private key<br/>configured?"}
    K -- no --> V0["verifiedUser = false"]
    K -- yes --> T{"siteverify<br/>success?"}
    T -- yes --> V1["verifiedUser = true"]
    T -- no --> V0
    V0 --> ROW["Row written to formFillEvents"]
    V1 --> ROW
    ROW --> OK["200 · thank-you state, chain continues"]
  end

  subgraph auth["CMS sign-in — /auth/sign-in/email"]
    A["Team member submits credentials"] --> AK{"Private key<br/>configured?"}
    AK -- no --> PASS["Request continues, unchecked"]
    AK -- yes --> AT{"Token present?"}
    AT -- no --> E400["400 · Captcha verification required"]
    AT -- yes --> AV{"siteverify<br/>success?"}
    AV -- yes --> PASS
    AV -- no --> E403["403 · Captcha verification failed"]
  end

Both paths call the same verification helper against the same Cloudflare endpoint. Only one of them acts on the answer.

SurfaceEndpointBlocking?What happens on failure
Published-site formsPOST /api/clerkNoverifiedUser: false is recorded and the fill is saved and processed normally
CMS sign-up and sign-in/auth/sign-up/email, /auth/sign-in/email, /auth/sign-in/magic-linkYes400 Captcha verification required with no token; 403 Captcha verification failed on a rejected one
Documentation reader sign-inthe Docs MCP one-time-code requestYesthe email step re-renders with Verification failed. Please try again. and no code is sent
Preview sitesPOST /api/clerkn/aCloudflare’s always-pass test key is in the widget, and the submission is discarded with a 204 regardless

If a colleague reports a 403 they cannot get past on the sign-in screen, that is this check and not a password problem — signing in covers what to do about it. The same challenge guards your readers’ documentation sign-in, where it stops an unattended script from having Leed mail one-time codes to arbitrary addresses.

Keys, and who sets them

There is no Turnstile screen in Leed, because there is nothing to fill in. When your workspace is provisioned, Leed creates one Turnstile widget for your company and scopes it to two hostnames: your custom domain, and the .leed.workers.dev address of the worker that serves your site. The widget’s site key is stored on your deployment record — that is the key the hidden div renders — and its secret is set on the public worker.

The preview worker is deliberately different. It receives Cloudflare’s always-pass test secret, and preview builds render Cloudflare’s always-pass test site key 1x00000000000000000000BB. Preview sites therefore never fail a challenge during QA — and never record a submission either, because preview traffic is discarded before any of this runs.

Re-provisioning rotates the widget rather than creating a second one, so a workspace has exactly one Turnstile widget for its whole life.

Reading verifiedUser

verifiedUser is a column on the submission row, alongside the values the visitor typed and the session Leed recorded them under. It is not one of the columns on the Submissions tab — that table shows the date, one column per field currently on the form, and the page. To read the verdict today you fetch the fills themselves, which the get_form_fills tool over the Operator MCP returns in full, verifiedUser and all. What a submission records lists the rest of the row.

That shapes what the field is good for. It is a forensic signal — read it when a burst of junk arrives and you want to know whether it came through a browser that passed a real challenge — not a filter you can act on before the junk lands.

What to do about spam today

Given that the check does not reject, these are the levers that actually work:

  • Add the standard Terms field. It is the one checkbox field the builder can produce, it is required-able, and a form that will not submit without a deliberate tick discards the crudest scripted fills. It is also the one field Leed Autofill never hides, so a returning visitor still has to tick it.
  • Put a pattern on the fields you care about. HTML5 pattern validation runs in the browser before the request is made. It will not stop a bot posting straight to the endpoint, but it removes a large share of nonsense entered by hand.
  • Send internal notifications to an address you can filter. The notification mail always arrives from forms-noreply@leed.ai with the subject Leed (<site title>) - Form Submission - <form name>, which is a clean rule in any mail client. Route it to a folder rather than to everybody’s inbox.
  • Gate the thing of value behind the response email, not behind the form. A junk submission with a fabricated address gets nothing, because the confirmation email and its attachment go to the address that was typed and no download is handed back in the response. That turns a fake email into a wasted request rather than a leaked document.
  • Sort out the contacts afterwards. Every submission with an email creates or updates a contact, spam included, and contacts count against your plan’s quota — contacts is where you delete the ones you do not want.

If none of that is enough for a form under sustained attack, the honest answer is that Leed has no per-form blocking control today, and that gap is recorded in known limitations.

ESC