How I made a contact form bot-killer using Cloudflare Turnstile CAPTCHA challenge (plus a bonus honeypot!)


I implemented Cloudflare’s Turnstile to protect my contact form from spam bots. Turnstile offers several interaction choices, including non-interactive, non-intrusive, and invisible challenges, which help improve user experience compared to traditional CAPTCHA methods. My implementation involves rendering the Turnstile widget, generating a token for verification, and validating the token server-side to ensure legitimate submissions. Additionally, I incorporated a "honeypot" trap as an extra layer of protection against bots.
The Idea
My contact form was completed and functioning correctly, but it was naked. Bots and automated senders could spam my account with unwanted junk, and no one wants that. My idea was to implement Cloudflare’s Turnstile. According to their excellent documentation, “Rather than try to unilaterally deprecate and replace CAPTCHA with a single alternative, we built a platform to test many alternatives and rotate new challenges in and out as they become more or less effective.”
They offer a few choices for interaction:
A non-interactive challenge (which I opted for)
A non-intrusive interactive challenge, such as checking a box, if the visitor is a suspected bot. No puzzles or images to decipher are displayed, which increases the friction that users usually experience on CAPTCHA-protected contact forms.
An invisible challenge to the browser. While the least intrusive, I believe having at least a visual gives some comfort to users that the entity they are giving their information to is at least somewhat concerned with privacy and security.
There are three primary interactions that occur when you visit a form protected by CAPTCHA, and I’ll break it down here along with providing the code snippets that I used in my implementation. If you want to skip ahead to the project documentation and live demo, check out the links below.
Part I: Rendering the Widget
In the first part, the user visits the website form and encounters the protection. A script in the head, calls the Cloudflare API to generate the widget and presents it to the user.
In the code below the following steps are carried out:
Mount Phase
Component mounts
Injects Turnstile script tag
Loads Turnstile API
Renders widget
Returns widget ID
Calls onWidgetId callback (if used in parent component)
The Widget is Rendered
/* ... Form rendering ... */
<Turnstile
theme="dark" // sets the theme
success={status === 'success'} // indicates success, removal of widget
{/* Other props/styling */}
/>
/*... Submission rendering ...*/
The Component Itself
// The script is injected into the head, and explicitly renders the widget
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
script.defer = true;
script.async = true;
script.onload = () => {
//The target container has an id of "cf-turnstile"
const id = window.turnstile.render('#cf-turnstile', {
//The public key is presented to the API to indicate who owns the widget and if the domain
//is allowed. The widget theme is also set.
sitekey: `${keys.cft_public_key}`,
theme: `${theme}`
});
//Upon rendering the widget, a widget id is generated for later manupulation
setWidgetId(id);
if (onWidgetId) onWidgetId(id);
};
document.head.appendChild(script);
Part II: Cloudflare API Generates a Token for Verification
As the widget is rendered, a token is generated by the Cloudflare API and returned to the turnstile widget for verification upon submission action by the user. On default, this token has a limited lifespan (300 seconds), and it must be regenerated if it becomes stale.
In the code below, the token is received as part of cf-turnstile-response
and stored by the widget.
const token = formData.get('cf-turnstile-response') as string;
Part III: The Token Is Submitted for Verification by a Worker
This last part is the most involved and is the meat and potatoes of the entire process: server-side validation.
Once the user hits “Submit,” the token is sent to the worker, which then passes it to the Siteverify API along with the super-secret key that verifies the token as originating from the specified website. Once the token is verified, a success response is sent back and the user is able to complete the requested action.
Retrieving the token from the form
try {
const result = await verifyTurnstileToken(token);
Sending the token to the worker for verification
export async function verifyTurnstileToken(token: string): Promise<TurnstileResponse | TurnstileError> {
try {
/* Worker URL for Turnstile verification */
const workerUrl = `${keys.worker_url}`;
const verificationResponse = await fetch(workerUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ 'cf-turnstile-response': token }),
});
The worker submits the token to siteverify, then formulates a response
const verificationResponse = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
//Here's the super-secret key
secret: env.CFT_SECRET_KEY,
//Here's the token from the user form
response: token,
//Here's the IP address of the user for validation
remoteip: ip,
}),
}
);
//If the response is successful, the worker returns a success with code 200. Otherwise
//it fails with code 400.
const outcome = await verificationResponse.json();
return createResponse(outcome, outcome.success ? 200 : 400);
//If there are any other errors, it responds with a code 500
} catch (error) {
console.error('Error:', error);
return createResponse({ success: false, error: 'Internal Server Error' }, 500);
}
}
};
Back at the form, the widget receives the response and acts accordingly
if ('success' in result && result.success) {
//If the verification is successful, the action exits this conditional and continues on with
//further submission logic, such as emailing the form or logging on.
setStatus('success');
//Otherwise, failures will stop the action from continuing on.
} else {
setStatus('error');
if ('message' in result) {
setErrorMessage(result.message || 'Verification failed');
} else {
setErrorMessage('Verification failed');
}
}
} catch (error) {
setStatus('error');
setErrorMessage('Verification failed');
}
};
Dismount!
Once the action is successful and complete, the component cleans up after itself.
Unmount Phase
Component unmounts
Removes script
Removes widget
return () => {
document.head.removeChild(script);
};
}, [onWidgetId, theme]);
/* Remove Turnstile widget after successful submission */
useEffect(() => {
if (success && widgetId && window.turnstile) {
window.turnstile.remove(widgetId);
}
}, [success, widgetId]);
And there is the full process of killing bots!
Render widget
Receive token
User hits “Submit”
Token sent for verification
Token is verified, then a response is returned
Submission action completes
Widget is cleaned up
But wait, there’s more!
Ah, yes. I promised you a bonus honeypot. In addition to the Turnstile/CAPTCHA protection, I added a little honeypot for bots to munch on. In my contact form, I don’t provide an input for a phone number, but I added a hidden field for a phone number in case automated systems or bots see a field and just fill it in with a random number. It’s not presented to an actual human, so there’s no way for them to fill it out.
Note: Bots wouldn’t normally pass the Turnstile in the first place, so the honeypot is actually redundant. I put it in there just for S&Gs.
Here’s the honeypot logic
const isBot = formData.get('phone') as string;
//Return success without sending email if bot
if (isBot) return json({ success: true }, { status: 200 });
//Rendering the honeypot field
{/* Honeypot for bots */}
<Input
id="phone"
required={false}
className={styles.botkiller} //hidden with CSS
label="Phone"
name="phone"
maxLength={MAX_EMAIL_LENGTH}
multiline={false}
autoComplete="phone"
type="phone"
{...phone}
/>
Subscribe to my newsletter
Read articles from Stephen J. Lu directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Stephen J. Lu
Stephen J. Lu
Stephen has studied everything from mosquitoes and disease biology to bloodstain patterns, bullet trajectories, and digging up clandestine graves.