The usual fix for bots submitting our signup form is a widget, and the widget alone is not the fix. Cloudflare’s Turnstile docs say the client-side widget alone does not protect your forms: your server must send each token to the Siteverify API. Skip that call and the form still accepts a request that carries no token at all.
What bot protection on a public form is
Bot protection on a public form is the set of checks that decide whether a submission came from a person before the app creates or sends anything. It has 5 layers: a honeypot and fill time, a challenge, a rate limit, email verification and the edge.
Bot protection is one control in web app security for an AI-built app, and it belongs on every form a stranger can reach, from the signup page to the newsletter box. In software, abuse prevention is the name for this family of controls against bots, floods and fake accounts.
The table is my reading of what each layer stops and what it costs a real person. None of it is a vendor figure.
| Layer | What it stops | What it costs a real person |
|---|---|---|
| Honeypot field and minimum fill time | The laziest scripts, which fill every field and submit at once | Nothing |
| Challenge (Turnstile, hCaptcha) | Most automated submissions | A pause, sometimes a checkbox or a puzzle |
| Rate limit per IP and per account | Volume from one source | Nothing, until they hit it |
| Email verification before the account can act | Fake addresses turning into users | One trip to the inbox |
| The edge (a CDN or firewall in front of the app) | Floods, before they reach the app | Nothing |
Below I cover the first two layers. The rate limit, the verification email and the edge are separate jobs, each with its own setup: rate limiting in API routes, how to send verification email that arrives and how to set up a web application firewall.
What goes wrong without it
Across the 21 third-party apps I audited in June and July 2026, the Input, Injection & Abuse pillar averages 61.9 out of 100. Those 21 are 11 public vibe-coded apps audited across all 12 pillars and 10 held-out apps audited blind: a selected set, not a random sample, so the average is no rate for AI-built apps in general. Why it matters on a form is plain: automated submissions can pollute accounts, reporting, and outbound communication.
Each row below uses the words an owner types when it happens. The middle column is my reading of what the bot was after.
| What you see | What the bot wanted | What it costs you |
|---|---|---|
| Contact form flooded with spam | Links placed in your inbox, or on any page that echoes submissions back | Real enquiries buried under junk |
| Fake signups filling up the database | Scripted accounts to farm a free tier, free AI credits or a referral bonus | Provider bills, and user counts nobody can trust |
| Junk leads from automated submissions | Nothing from you in particular; the CRM and the sales follow-up fill with invented people | Reporting and ad attribution that lie |
| A signup form mailing strangers | The bot types someone else’s address, and your app sends them a verification email | Complaints landing on your sending domain |
Repeated-account trial abuse is a different layer again, with its own fixes: free trial abuse and repeated-account signups. When those unwanted verification emails bounce or get reported, the damage shows up in deliverability, so the email bounce handling checklist becomes its own piece of work.
One AI coding workspace I audited rate-limited its cheap AI chat route but not the two routes that start cloud sandboxes and run commands, which cost far more. My take: abuse goes where the cost is, so the form or route that spends money on your account is the one to protect first, and it pays to cap monthly usage on a metered API as well.
A challenge on the login form is a different problem, credential stuffing, which belongs with brute force login protection.
Bots submitting our signup form: what to do, in order
Stopping bots on a signup form takes 6 steps: find where the form posts, add a honeypot and a fill-time check, add the Turnstile or hCaptcha widget, verify its token on the server before any insert or email, handle the three failures, and test with no token.
The order matters because step 1 decides where step 4 runs. The vendor behavior in this section comes from each vendor’s own documentation, read on September 30, 2026.
- 01 Find where the form actually posts: your own API route, a server action, or straight to a hosted auth or form service. Open DevTools, go to the Network tab, submit the form, and read the host the request goes to.
- 02 Add a hidden honeypot input and a minimum fill time, both checked on the server.
- 03 Add the challenge widget with your site key. Turnstile and hCaptcha each put a token into the form when the challenge passes.
- 04 Verify the token on the server with your secret key before any insert, email or third-party call. If the form posts to a hosted auth service, turn on that service's CAPTCHA setting instead.
- 05 Handle the three failures: a missing token, an invalid token, and a verify call that does not answer. Each one gets a message a person can act on.
- 06 Run the checks in the verification section below, starting with a request that carries no token.
Whichever form you are protecting, the way to prevent bots from filling out forms is this same order of work. The honeypot in step 2 can prevent spam form submissions from the laziest scripts on its own, but only the server check in step 4 turns the widget into protection.
For an invalid or expired token, Cloudflare’s error table says the user should retry the challenge, and the widget has to issue a fresh token for that, so the message asks the person to complete the check again. A missing token from a real person can mean the widget never loaded, a blocked script for one, so that message asks them to reload the page. For the third failure, Cloudflare’s advice is to have fallback behavior for API failures and not to wait indefinitely for a Siteverify response. My working rule: decide in writing, before launch, whether the form fails closed (it refuses, with a message and a contact address) or fails open (it accepts, with the rate limit as the backstop), and never fail open without that rate limit in place.
How to add Turnstile to a form
Adding Turnstile to a form has two halves: the widget, which puts a token in the submission, and a server call to Cloudflare’s Siteverify API with your secret key, which says whether the token is real. Cloudflare’s docs say the widget alone does not protect the form.
To add Cloudflare Turnstile to your website you need nothing else from Cloudflare: its plans page says Turnstile can be used without other Cloudflare services, and the Free plan allows up to 20 widgets.
- 01 Create a widget in the Cloudflare dashboard. Each widget gets its own site key and secret key.
- 02 Load Cloudflare's api.js script on the page and place the cf-turnstile container, with your site key, inside the form.
- 03 Submit once and confirm the request now carries a cf-turnstile-response field next to your other fields.
- 04 On the server, post that token and your secret key to the Siteverify endpoint, and continue only when the response says success is true.
- 05 Keep the secret key in a server environment variable, never in client code, and keep the testing keys out of production.
Cloudflare’s guide to validating Turnstile tokens gives three reasons the server call is mandatory. Tokens can be forged, because anything can be posted to your endpoint without a challenge ever running. Each token is valid for 300 seconds (5 minutes) after generation. And each token can only be validated once: a replayed token is rejected with the timeout-or-duplicate error code. So verify once, at submit, and only on the server.
// Server only: the secret comes from a server environment variable.
const token = (await request.formData()).get("cf-turnstile-response");
if (!token) return new Response("Please reload the page and try again.", { status: 400 });
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: token }),
});
const result = await res.json();
if (!result.success) return new Response("Verification failed, please try again.", { status: 400 });
// Only now: insert the row, send the email, call the CRM.
Wrap that fetch in a timeout and a catch that applies your outage decision; a verify call that hangs is the third failure from step 5.
Cloudflare publishes testing keys that always pass or always fail, and says your production secret keys will reject the dummy tokens the testing site keys produce. The risk runs the other way too: a testing secret key accepts only the dummy token, and that dummy token is printed in Cloudflare’s docs for anyone to send. Keeping every testing key out of the production environment is my own rule, and check 6 below tests it.
If your site sends a Content Security Policy, Cloudflare says you must allow connections to challenges.cloudflare.com, and hCaptcha lists the script, frame, style and connect sources it needs. An outside script is exactly where the content security policy unsafe-inline rules for third-party scripts come into play.
What is hCaptcha, and what a challenge actually proves
hCaptcha is a challenge service, run by Intuition Machines, that may show a visitor a visual challenge and adds a token to the form for your server to verify. Like Turnstile, a passed challenge means a person was probably present a moment ago. It does not prove who, or that the email is theirs.
hCaptcha’s developer docs note that a challenge in its system does not automatically mean a visual one, and they place passive, no-CAPTCHA modes among the Enterprise features. The token arrives as h-captcha-response and is verified at https://api.hcaptcha.com/siteverify as a URL-encoded POST; the docs say not to send JSON there. So an hCaptcha version of the Turnstile code above changes three things: the field name, the URL, and a form-encoded body in place of JSON.
Every cell below comes from the vendor’s own docs or hCaptcha’s pricing page and plans page, read on September 30, 2026.
| Question | Cloudflare Turnstile | hCaptcha |
|---|---|---|
| Who runs it | Cloudflare | Intuition Machines, Inc. |
| What the visitor sees | Managed mode asks for a checkbox only when a further check is needed, with no images or text to decipher; non-interactive and invisible modes ask for nothing | The widget, which may lead to a challenge; passive modes are on the paid plans |
| Free tier | Free plan: up to 20 widgets, unlimited challenges | Basic (Free) plan; hCaptcha’s plans page says free up to 10,000 requests per month |
| Verify endpoint | challenges.cloudflare.com/turnstile/v0/siteverify, JSON or form-encoded | api.hcaptcha.com/siteverify, form-encoded only |
| Token lifetime | 300 seconds, single use | Single use; the expired-input-response code notes a 120-second default |
| Accessibility option | Cloudflare’s overview calls Turnstile “WCAG 2.2 AA compliant” | A text-based Accessibility Challenge a site may enable, plus an accessibility authorization available by default |
What a passed challenge does say, in my reading, is that the vendor judged this browser session likely to be a person a moment ago. It says nothing about identity or about who owns the inbox, so a challenge sits beside email verification and a rate limit, never in their place.
My choice for a small app: either works. The integration and the tests below have the same shape for both, so pick on what your visitors see and on whether you want the challenge from the same company as your CDN.
The challenge vendor receives data about your visitors, so it belongs on your list of subprocessors. Cloudflare also makes a reference to its Turnstile Privacy Addendum in your own privacy policy a condition of using invisible mode.
When the form posts straight to your auth provider
A form that calls a hosted auth service from the browser never touches an endpoint of yours, so a check in your own API route protects nothing. Supabase Auth supports hCaptcha and Cloudflare Turnstile on its sign-up, sign-in and password reset forms, and that is where the token is verified.
A builder-generated app can call supabase.auth.signUp straight from the browser, with no server code of yours in between. The fix is a setting, described in Supabase’s CAPTCHA protection guide: in the dashboard, Settings > Authentication > Bot and Abuse Protection > Enable CAPTCHA protection, where you pick the provider and enter your CAPTCHA secret key. The client then passes the token in the auth call as options: { captchaToken }, and Supabase’s guide resets the challenge after each call. A token works once, so the next attempt needs a new one.
The same question applies to any hosted form backend or waitlist tool: the check has to happen wherever the submission is accepted. The Network-tab look from the first step tells you which case you are in. A request to your own domain means your route does the check. A request to the auth provider’s domain means the provider’s setting does.
The cheap layers: a honeypot field and a minimum fill time
These two are my method, not a vendor’s. A honeypot is an extra input hidden from people with CSS, inside a wrapper marked aria-hidden="true", with tabindex="-1" so the keyboard skips it and autocomplete="off" so most autofill leaves it empty. The server rejects any submission where it has a value. Give it a name autofill will not match, such as company_url, never email.
A minimum fill time rejects submissions that arrive faster than a person can type. Put the render time in a signed value from the server, not a plain hidden field a bot can edit, and compare it on submit.
Both are silent, and both are beaten by any bot written for your form, so they sit under the challenge, never instead of it. When either one trips, return the same success message a real person gets, so the script learns nothing.
CSRF protection on the same form is a different token with a different job: a CSRF token shows the request came from your page, while a challenge token says a person was probably there. Both belong on the form, and CSRF mitigation and CORS in production is the other half of that pair.
How to verify it
Bot protection is verified by 7 checks. The one that matters most: replay the form’s request with no token and confirm a 4xx response, no new row and no email. Then confirm a real person on a phone still gets through.
Each check says what you should see, and each one runs against your own form. The missing-token replay is the test that shows your server, not the widget, is doing the work: a CAPTCHA check that rejects a missing token also stops a bot that never loaded the page.
To build the replay, find the form’s request in the Network tab, right-click it, choose Copy, then Copy as cURL, and delete the token field from the body. Stripped down, it looks like this:
curl -i -X POST https://your-app.example/api/signup \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "email=no-token-test@example.com&password=a-long-test-password"
- 01 A real person submits the form on a phone and on a desktop. Seen: the row or the email appears.
- 02 Replay the form's request with curl and no token field. Seen: a 4xx response, no new row, no email sent.
- 03 Send a made-up token, then replay a token that already succeeded, with a new email address in the copy. Seen: both refused, and the error-codes field of the verify response names the reason.
- 04 Block the challenge script in DevTools and submit; then, in staging, point the server's verify call at an address that does not answer and submit again. Seen: the form does what you decided in writing, never a spinner that runs forever.
- 05 Complete the form with the keyboard only, then with a screen reader. Seen: every field and the challenge can be reached, and the challenge offers its accessible path.
- 06 Compare the production secret key with the vendor's published testing keys. Seen: it matches none of them.
- 07 Compare form completions and signups for the week before and the week after, and read the challenge's own analytics. Seen: junk falls and real signups do not.
For check 3, Turnstile answers a made-up token with invalid-input-response and a reused one with timeout-or-duplicate; hCaptcha uses invalid-input-response and already-seen-response. hCaptcha’s docs suggest a nonsense string such as FAKE-TOKEN for the made-up one.
Check 4 has two halves because the verify call runs on your server, so blocking it in the browser tests nothing. On the auth-provider path the provider makes that call, so only the script half applies. In Chrome, right-click the script’s request in the Network panel and choose Block request; closing DevTools turns blocking off again.
For check 5, the reference for login and other authentication steps is WCAG 2.2’s accessible authentication criterion (3.3.8, Level AA). It says a cognitive function test, such as remembering a password or solving a puzzle, is not required for any step in an authentication process unless that step offers an alternative, a mechanism to help, object recognition or personal content. I use it as a reference, not as a compliance test; the full pass over the app is an accessibility audit for a web app.
For check 7, Turnstile’s Free plan keeps analytics for 7 days at most, so save the numbers before they roll off; hCaptcha lists analytics under its paid Pro plan. Keep the evidence together: the curl output with its date, the vendor dashboard’s numbers, the two weeks’ counts and the written outage decision.
In the Production Hardening Sprint, deliverable 3.8 is verified this way: confirm valid users can submit and rejected or missing bot challenges are handled safely.
The bot protection checklist for public forms
Tick these nine lines for every public form: signup, contact, waitlist, password reset request and newsletter.
- Where the form posts is known and written down.
- Honeypot and minimum fill time checked on the server.
- Challenge widget present on the form.
- Token verified on the server, or in the auth provider’s CAPTCHA setting, before any insert, email or third-party call.
- Secret key only in server environment variables.
- Missing, invalid and reused tokens refused.
- Outage behavior decided in writing and tested.
- Keyboard and screen reader pass.
- Before-and-after numbers recorded.
Where the sprint does this
Deliverable 3.8 of the Production Hardening Sprint, Bot protection, adds bot protection to public forms using Turnstile, hCaptcha, or an appropriate equivalent, and its verify line is the one closing the checks above. Public-endpoint rate limits are a separate deliverable, 3.6, which protects public forms, signup flows, and resource-consuming endpoints with appropriate limits. Both are recorded in the production readiness report, deliverable 13.1, which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. The fee covers our engineering work: hosting, paid tools, and API usage remain in your accounts, and we explain any required third-party costs before enabling them. Every item is listed in the published scope.
Common questions about bots and form challenges
Can bots fill out forms?
Yes. Cloudflare’s docs put it plainly: “An attacker can submit any string to your form endpoint without completing a challenge.” That is why the token check has to run on your server, where a script cannot skip it.
Can a robot pass the CAPTCHA test?
Yes, it can, because a passed challenge is the vendor’s judgment that a person was probably present, not proof of one. That is why the challenge is one layer of five here, with the honeypot underneath and the rate limit, email verification and the edge around it.
Is hCaptcha free to use?
Yes. hCaptcha’s pricing page lists a Basic (Free) plan, checked on September 30, 2026. hCaptcha’s plans page says it is free up to 10,000 requests per month, and the pricing page lists the lower-friction passive mode and analytics under the paid Pro plan.
What is the difference between reCAPTCHA and Turnstile?
reCAPTCHA is Google’s challenge service and Turnstile is Cloudflare’s, and the practical difference is what the visitor sees. Both follow the same pattern: a widget in the page and a token your server verifies.
Google’s reCAPTCHA versions page describes v3 as returning a score without any user interaction, v2 as a checkbox that may pass the user or lead to a challenge, and the v2 invisible badge as prompting only the most suspicious traffic by default. Cloudflare says Turnstile works without showing visitors a CAPTCHA, and its managed mode asks for a checkbox only when a further check is needed.
How to fix invalid CAPTCHA token?
Log the error code your verify call returns and fix what it names: on your side that is an expired token, a token verified twice, a site key and secret key from different widgets, or testing keys mixed with real ones.
A Turnstile token lasts 300 seconds and hCaptcha’s expiry code notes a 120-second default, so a form left open too long needs a fresh token. A second verify of the same token returns timeout-or-duplicate on Turnstile and already-seen-response on hCaptcha. hCaptcha reports mismatched keys as sitekey-secret-mismatch and a testing site key used with a real secret as not-using-dummy-passcode; on Turnstile, each widget has its own key pair and production secrets reject dummy tokens.
The checks in this guide show you where the app is open. The sprint below closes those gaps, tests the result and writes the evidence down.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase