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 challengeVerification failed. Please try again.
Which post matches your status code?
| HTTP status | Typical message | Fix |
|---|---|---|
| 503 | Contact form is not configured. Set Turnstile keys on the server. | 503 Turnstile not configured |
| 400 | Please complete the verification challenge. | Token missing in JSON — see DevTools below |
| 403 | Verification failed. Please try again. | This post — siteverify returned success: false |
| 502 | Could not send email (403) or (422) | Resend 403/422 |
| 404 | HTML or empty body | API route 404 |
| 429 | Rate limited | Cloudflare 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 verifiedturnstile verification failed.turnstile validation failedincorrect 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 key →
PUBLIC_TURNSTILE_SITE_KEY(public Worker variable) - Secret key →
TURNSTILE_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.ioandwww.mayfield.ioif both serve the formlocalhostfor 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:
| Code | Usually means |
|---|---|
invalid-input-response | Token missing, expired, or already used — submit again from the browser |
invalid-input-secret | Wrong TURNSTILE_SECRET_KEY or not paired with the site key |
timeout-or-duplicate | Token replayed or challenge timed out — refresh and resubmit |
missing-input-response | Server 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.
- 503 → 503 post.
- 200 without a token → verification not enforced — check
isTurnstileConfiguredand production vs dev.
Use a normal browser submit for the happy path; do not paste live tokens into tickets.
Debug in browser DevTools
- Open Network, submit the form, select
POST /api/contact. - Request payload should include
turnstileTokenwith 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
wwwvs apex not both in Turnstile hostnamesTURNSTILE_SECRET_KEYonly 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
- Open the Turnstile widget → copy site + secret as a pair.
- Set
PUBLIC_TURNSTILE_SITE_KEYandTURNSTILE_SECRET_KEYon the Worker; redeploy. - Confirm hostnames include the live origin.
- Submit once from production
/contact; expect 200 and inbox delivery. - If still 403, log
error-codesonce, 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.