Cloudflare Turnstile: What Kept Breaking My Signup Form A developer's investigation into a broken newsletter signup form found that Cloudflare Turnstile's readiness callback could spawn a second widget while a signup POST was pending, and an error from that widget could abort the in-flight request. The developer traced the failures to an invalid server secret, generated HTML that dropped the public sitekey, and an AI-assisted repair that used a readiness method the real Cloudflare API rejected, replacing the incompatible ready() call with deferred onload handling. All 21 widget error reports carrying phase evidence showed idle/not-started states, so the underlying challenge-failure cause remains unknown, and the fixes did not establish how many readers were lost. Cloudflare Turnstile: What Kept Breaking My Signup Form My newsletter form could cancel its own signup request after Turnstile reported success. In a controlled reproduction, a repeated readiness callback created another widget while the POST was pending. An error from that widget could abort the request already in flight. That was one failure in a longer investigation of my blog's signup form. A server secret had been invalid. Generated HTML had lost the public key. An AI-assisted repair passed local tests but used a readiness method the real Cloudflare API rejected. Meanwhile, idle widget errors made the logs look like failed submissions. The code now makes ownership explicit: which attempt owns a callback, which durable record permits an email, and which observation establishes acceptance. A token, an HTTP response and an active subscription each prove different things. The changes below fixed reproducible defects and gave those stages separate evidence. They did not explain every challenge failure or establish how many readers I had lost. Find the last stage you can prove Turnstile's widget uses a public sitekey; server-side validation uses a private secret. Between the generated page and an active subscription, there are several opportunities for one part to work while the next part fails. For a similar integration, I would start with this diagnostic map. The final column distinguishes findings from the validation policy I implemented. | Symptom | Stage to inspect | Decisive check | Finding or policy here | |---|---|---|---| | invalid-input-secret | Server credential | Read the actual Siteverify result; test the deployed widget and secret together | Installing an existing recognized secret was followed by successful real verification | | Widget cannot initialize because its sitekey is missing | Generated browser configuration | Read the served form's dataset and render that form through the actual SSG | Direct attribute binding restored the key; a hand-built test DOM had hidden the defect | | Readiness exception with a deferred script | Native script integration | Exercise the real API with the actual script tag and initialization method | Deferred onload handling replaced the incompatible ready call | | 300031 in a widget error report | Challenge lifecycle | Read the attempt phase and operation state alongside the code | All 21 reports with phase evidence said idle/not-started; their challenge-failure cause remains unknown | | Token rejected after the browser obtains it | Server validation | Inspect the validation result and the application's hostname/action policy | Policy: distinguish expiry/reuse, service failure and hostname/action rejection | | Accepted response with no new email | Signup outcome | Join the request to its admission/send record, then check mailbox receipt and activation | Some accepted requests intentionally send nothing; one designated fresh cycle completed | An absent report is another uncertainty. Reporting can fail, and stored records have retention limits. This map depends on positive evidence at the relevant stage; it cannot reconstruct every earlier attempt. Make callbacks belong to one attempt The client already tracked verification and sending, but its readiness path could create a widget outside that lifecycle. A ready event was allowed to render while the form was already sending. Its error callback then entered the same failure handler used by verification and aborted the pending POST. The attempt model https://github.com/gkoreli/blog/blob/b94063aa/packages/blog/src/client/subscription-attempt.ts now owns one identity and a small lifecycle: verifying, sending, settled. Waiting for the API, rendering and interaction are steps inside verification. Its commands check both state and the deadline. Timer callbacks can arrive late in a background tab; checking the clock only in a timer would leave expired commands usable. The DOM and provider adapter own a separate widget generation. Retiring that generation happens before cleanup, so a callback fired during removal is already stale. The model contains no DOM, fetch, email address or provider object. | Event ordering reproduced locally | Rule enforced by the repair | |---|---| | Readiness repeats during a held POST | Sending cannot create a verification widget | | execute calls success synchronously, then throws | Token handoff has already transferred ownership to sending; the old stack cannot cancel it | | An old render returns after a replacement widget exists | Old cleanup cannot clear the replacement's slot | | Both native removal and the DOM fallback throw | Cleanup is reported separately and cannot prevent the single signup POST | | A token or expiry callback repeats after handoff | A retired widget cannot send again or settle the request | My recovery allowlist covers 300 / 600 error callbacks, iframe load error 200500 , and timeout codes 110600 / 110620 . It permits one fresh widget under the same attempt identity and existing deadline. Native retry is set to 'never' ; I own that recovery rather than combining two retry mechanisms. A repeated failure restores manual retry. Eligibility is a recovery policy, not a diagnosis of an internal suffix. Native browser controls checked the actual generated forms and client with Cloudflare's native script and official dummy keys https://developers.cloudflare.com/turnstile/troubleshooting/testing/ , which give fixed test behavior. Two concurrent forms each started one POST, survived repeated readiness while both responses were held, then completed when the local receiver released them. An always-failing dummy key produced one replacement, two reports under one attempt and no POST. Those controls exercise real script integration against a local receiver. They do not test a human checkbox, production hostname enforcement or email delivery. Browser reproductions and native receipts https://github.com/gkoreli/blog/blob/b94063aa/packages/blog/drafts/research/newsletter-reliability/33-browser-attempt-domain.md . The fake API accepted what the real API rejected The readiness regression was an example of a test missing the contract it needed to check. I asked the agent to read the authoritative documentation and native API capabilities. It added turnstile.ready . The fake implementation in its tests called the supplied callback immediately. The deployed browser failed, and inspection of the captured public API identified the async/defer guard and native error 3857 . Cloudflare's rendering documentation https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/ includes a deferred-script example with an onload callback. Its advanced example using ready loads the script synchronously. We combined the loading mode from one example with the readiness method from another. The corrected integration installs a global callback before loading the provider script: