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.

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.

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
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.
Form lookup
cf_slug must match an enabled form. No match means cf_status=error before anything else is checked.
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.
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.
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.
Field validation
Each field is checked against the form definition. Failures return cf_status=invalid with the bad keys echoed in cf_fields.
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.
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
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.
Open the plugin settings
Admin → Plugins → Contact forms → Settings. The two Turnstile fields only exist on 1.1.0 and newer.
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.
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.
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.

Test it from the outside
These are real responses from this site's live endpoint, not a mock. A tokenless POST must be challenged:
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"HTTP/2 303
location: /?cf=contact&cf_status=challengeNo 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.
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"HTTP/2 303
location: /?cf=contact&cf_status=sentThat 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

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.

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