BlogFeatured

Stop contact form spam with Cloudflare Turnstile

The contact form on this site was feeding the inbox junk. Turnstile took it to zero: the real pipeline, the exact fields a bot must fake, verified curl tests, and the cache trap we hit.

A paper mailbox behind a small turnstile gate, letters held at the checkpoint

The contact form on this site runs exactly the setup below. Before Turnstile it was collecting scripted submissions - the built-in filters caught some, but enough got through to make the inbox a chore. After adding it, junk posts stopped creating rows entirely: what lands in the inbox now is real people and nothing else.

The short version: the Contact Forms plugin renders a Cloudflare Turnstile widget on the form, the browser hands back a token, and the submit route verifies that token with Cloudflare before it stores anything or sends mail. A POST without a valid token gets a 303 redirect to cf_status=challenge - no database row, no email. Everything below is the real pipeline from the plugin source, plus tests I ran against the live form.

The submission pipeline: envelope, honey pot, clock and turnstile gate guarding the inbox
The submit pipeline: the honeypot and fill-time traps run first and lie to the bot, Turnstile verifies the token at the door, and only then does anything reach the inbox.

What you need

  • A Cloudflare account (the free plan covers Turnstile)
  • Contact Forms plugin 1.1.0 or newer
  • Two keys from the dashboard: the site key and the secret key
  • About fifteen minutes

How a submission is judged

Every POST to the submit route passes these gates, in this order. The order matters: a request can only fail one gate, and which one tells you what the sender was.

The gates, in order

  1. Honeypot

    The cf_hp field is invisible to humans and bots fill it. Tripping it returns a fake cf_status=sent and stores nothing - the bot thinks it won.

  2. Form lookup

    cf_slug must match an enabled form. No match means cf_status=error before anything else is checked.

  3. Minimum fill time

    The hidden cf_ts field carries the page-load timestamp. A submit under 1.5 seconds old is a bot and gets another fake sent.

  4. Turnstile verify

    When both keys are set, cf-turnstile-response goes to Cloudflare siteverify with the secret key and the visitor IP. Missing or rejected token: cf_status=challenge.

  5. Rate limit

    Counts stored submissions from the same IP in the last hour against the configured cap, 20 by default. Over it: cf_status=rate_limited.

  6. Field validation

    Each field is checked against the form definition. Failures return cf_status=invalid with the bad keys echoed in cf_fields.

  7. Store

    The row is written first - before email - so a mail outage never loses a message. That is why a broken mail config shows status saved, not sent.

  8. Email

    The notification goes through the site's configured provider, and the row records the attempt. If the form has send confirmation on, the visitor gets an auto-reply too.

The two contracts

Everything the route needs is in the posted form data. These are the real field names - the prefix matters, and getting it wrong is the first trap:

Posted fields

cf_slug
The form slug to submit to. Plain "slug" is ignored and returns error
cf_hp
Honeypot. Must be empty; the input is hidden and never filled by humans
cf_ts
Page-load timestamp in ms; posts younger than 1.5 s are bots
cf-turnstile-response
The token Cloudflare's widget writes into the form
cf_page
Optional URL the submit came from, stored on the row
your fields
Whatever the form defines - name, email, subject, message here

The answer is never JSON. The route 303-redirects back to the referring page with cf_status in the query string, so a plain HTML form with no JavaScript still completes the loop - the browser just lands on a page that can read the status.

Set it up

Five steps

  1. Create a Turnstile site

    In the Cloudflare dashboard, open Turnstile and choose Add site. Enter your real hostname and keep the Managed widget mode. Cloudflare shows the site key and secret key once; copy both.

  2. Open the plugin settings

    Admin → Plugins → Contact forms → Settings. The two Turnstile fields only exist on 1.1.0 and newer.

  3. Paste both keys and save

    The site key renders the widget; the secret key verifies tokens. Set only one and the gate stays off: a half-configured pair is treated as unconfigured, not half-on.

  4. Redeploy or purge the cache

    If pages are cached, the cached HTML has no widget on it. Cached pages keep collecting challenge failures until the cache refreshes.

  5. Test it

    Load the form page, confirm the widget, submit once yourself for the happy path, then run the curl below for the bot path.

The contact forms plugin settings page showing the Turnstile key fields
The plugin settings screen: the Turnstile site key and secret key sit next to the built-in rate limit.

Test it from the outside

These are real responses from this site's live endpoint, not a mock. A tokenless POST must be challenged:

bash
curl -i -X POST https://emdashhq.com/_emdash/api/plugins/emdashhq-contact-forms/submit \
  -F "cf_slug=contact" -F "name=Bot" -F "email=bot@example.com" \
  -F "subject=test" -F "message=hi"
what came back
HTTP/2 303
location: /?cf=contact&cf_status=challenge

No row is written, no mail goes out. Now the trap I fell into writing this: my first test posted slug=contact instead of cf_slug=contact and got cf_status=error - the route read an empty slug and reported the form missing, which is honest but misleading when you typo the field name. If you see error instead of challenge, check the field names before the keys.

bash
curl -si -X POST https://emdashhq.com/_emdash/api/plugins/emdashhq-contact-forms/submit \
  -F "cf_slug=contact" -F "name=Bot" -F "email=bot@example.com" \
  -F "subject=test" -F "message=hi" -F "cf_hp=spammy"
honeypot filled - fake success
HTTP/2 303
location: /?cf=contact&cf_status=sent

That sent is a lie in the good direction: the response says success, the database got nothing. The submissions inbox on this site held eight rows when I checked - all real, every one with an email status recorded.

What the status codes mean

sent
Stored and emailed - or a fake success fed to the honeypot and time traps
saved
Stored, but the notification email failed; the row keeps the error
challenge
Turnstile token missing or rejected; nothing was stored
rate_limited
The hourly per-IP cap tripped
invalid
Fields failed validation; the bad keys echo back in cf_fields
error
Form slug unknown or disabled - or too many posted fields
The submissions inbox listing stored form entries with status and email columns
Submissions that pass land in the inbox with an email status for each one. Blocked posts never get a row.

What a stored row contains

Each passing submission records more than the answers - enough to debug delivery problems later without a second system:

  • The answers plus the field labels at submit time
  • IP, user agent and country (from the edge request)
  • The page URL the submit came from
  • emailStatus and emailAttempts, so a provider outage is visible, not silent
  • A new / read / flagged state you can triage in the inbox

The fail-open trade-off, honestly

When Turnstile is configured but siteverify cannot be reached, the plugin accepts the submission. The reasoning: a Cloudflare outage should never take your contact form down with it. The cost: a silently broken verifier quietly turns the spam check off - you find out via a spam spike, not an error. If junk suddenly reappears, suspect the keys before the bots got smarter.

Tuning it afterwards

Worth adjusting

  • Rate limit per IP per hour: 20 by default, accepts 1 to 1000; the count is over stored rows, so challenged posts do not eat the budget
  • Keep submissions for (days): 0 keeps everything forever
  • Confirmation email: each form can auto-reply to the visitor's email field - the contact form here has it on
  • Extra debug: plugin settings has a switch that turns on verbose submit logs
  • Submissions export to CSV from the admin inbox when you need them out

Worth knowing

Price
Free with any Cloudflare account
User friction
Usually invisible; Managed mode only challenges suspects
Privacy
No tracking cookies, no puzzle farm
Mode used here
Managed

Common questions

Does it stop all spam?

It stops automated posts and most scripted junk. A determined human can still pass a challenge. Flagged submissions can be quarantined in the inbox: stored as spam, never emailed.

Does it work without JavaScript?

The widget needs JavaScript in the browser. That is the point: bots posting the raw endpoint get challenged regardless.

What if Cloudflare is down?

Verification fails open: submissions are accepted so the form never goes offline with them. Watch for a spam spike as the telltale.

Where do real submissions go?

The admin inbox under Plugins → Contact forms, plus an email through the site's configured provider.

The public contact form rendered on the site
The live form on this site: in managed mode the widget renders nothing until Cloudflare decides a visitor needs a challenge.

Done means

  • The widget renders on the form page
  • A tokenless POST gets cf_status=challenge
  • A filled honeypot gets a fake sent and stores nothing
  • A real submit is stored and emailed
  • A quiet inbox for a week