A cat resting on a computer keyboard

Fix “Turnstile verification failed” on Cloudflare (403): complete checklist


If your contact form works locally but fails in production with errors like:

  • Please complete the verification challenge
  • Verification failed. Please try again.

Which post matches your status code?

HTTP statusTypical messageFix
503Contact form is not configured. Set Turnstile keys on the server.503 Turnstile not configured
400Please complete the verification challenge.Token missing in JSON — see DevTools below
403Verification failed. Please try again.This postsiteverify returned success: false
502Could not send email (403) or (422)Resend 403/422
404HTML or empty bodyAPI route 404
429Rate limitedCloudflare WAF on /api/contact — wait and retry; not a Turnstile key issue

Turnstile verification failed. Please try again.

That UI copy means the browser got 403 from POST /api/contact after Cloudflare siteverify returned success: false. Search Console phrases like turnstile verification failed or failed to verify cloudflare turnstile token describe the same failure.

Also seen as:

  • turnstile has not verified
  • turnstile verification failed.
  • turnstile validation failed
  • incorrect turnstile response. try the verification again.

If you only fix one thing: confirm the site key and secret came from the same Turnstile widget in the Cloudflare dashboard. Mixing keys from two widgets is the most common 403 I see after deploy.

Fast diagnosis checklist

1) Pair site key and secret from one widget

In Cloudflare → Turnstile, open the widget you use on /contact. Copy:

  • Site keyPUBLIC_TURNSTILE_SITE_KEY (public Worker variable)
  • Secret keyTURNSTILE_SECRET_KEY (Worker secret)

Do not reuse a secret from an old widget after rotating the site key, and do not paste keys from a “test” widget into production env.

2) Allowed hostnames (apex, www, staging)

Under the widget’s hostname settings, include every origin users actually submit from:

  • mayfield.io and www.mayfield.io if both serve the form
  • localhost for local dev
  • Preview or staging hosts if you test there

Submitting from www while only mayfield.io is allowed (or the reverse) produces valid-looking widgets and failed siteverify.

3) Confirm both keys exist in production

If TURNSTILE_SECRET_KEY is missing at request time you get 503, not this 403. For 403, the secret is usually present but wrong for the token or domain.

4) Verify server calls siteverify

src/lib/contact.ts posts to:

https://challenges.cloudflare.com/turnstile/v0/siteverify

with secret, response (the token), and optional remoteip. Mail sends only when success === true.

5) Read error-codes once

The verify response can include error-codes when success is false. This stack only checks success in production; for debugging, log the full JSON temporarily:

const data = (await res.json()) as { success?: boolean; 'error-codes'?: string[] };
console.log('turnstile siteverify', data);
return data.success === true;

Common codes:

CodeUsually means
invalid-input-responseToken missing, expired, or already used — submit again from the browser
invalid-input-secretWrong TURNSTILE_SECRET_KEY or not paired with the site key
timeout-or-duplicateToken replayed or challenge timed out — refresh and resubmit
missing-input-responseServer did not send the token to siteverify

Remove verbose logging before you leave the branch.

6) Curl without a token (routing sanity check)

Without turnstileToken, POST should return 400, not 200:

curl -i -X POST "https://yourdomain.com/api/contact" \
  -H "Content-Type: application/json" \
  -d '{"name":"Test","email":"test@example.com","message":"hello from curl probe"}'
  • 400 + verification challenge → route and Turnstile gate are wired; debug widget/domain/key pairing.
  • 503503 post.
  • 200 without a token → verification not enforced — check isTurnstileConfigured and production vs dev.

Use a normal browser submit for the happy path; do not paste live tokens into tickets.

Debug in browser DevTools

  1. Open Network, submit the form, select POST /api/contact.
  2. Request payload should include turnstileToken with a long string. On this site the client reads Turnstile’s hidden field:
const turnstileInput = form.querySelector('input[name="cf-turnstile-response"]');
// ...
turnstileToken: turnstileInput?.value || undefined,

If turnstileToken is empty, the widget did not run, the site key is wrong, or an ad blocker stripped the script. 3. Response with status 403 and Verification failed. Please try again. confirms server-side rejection after siteverify.

Worker logs

wrangler tail (or dashboard Workers → Observability → Logs) should show POST /api/contact with 403 when verification fails. No log line often means the request never hit the Worker — see Why Worker logs only show some routes and API route 404.

Common production mistakes

  • Site key from widget A, secret from widget B
  • www vs apex not both in Turnstile hostnames
  • TURNSTILE_SECRET_KEY only in .dev.vars, not in dashboard secrets
  • Stale deploy after wrangler secret put
  • Double-submit within WAF window (429) mistaken for Turnstile failure

Concrete fix flow

  1. Open the Turnstile widget → copy site + secret as a pair.
  2. Set PUBLIC_TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY on the Worker; redeploy.
  3. Confirm hostnames include the live origin.
  4. Submit once from production /contact; expect 200 and inbox delivery.
  5. If still 403, log error-codes once, fix the matching row above, remove logging.

For the full stack, Turnstile + Resend contact form setup. For local files vs dashboard, Local .dev.vars vs production secrets.


Hero photo: Cat on keyboard, freely licensed on Wikimedia Commons.