title | seoTitle | seoDescription | datePublished | cuid | slug | cover | tags |
---|---|---|---|---|---|---|---|
How I made a contact form bot-killer using Cloudflare Turnstile CAPTCHA challenge (plus a bonus honeypot!) |
Contact Form Security with Cloudflare Turnstile |
Improve contact form security with Cloudflare Turnstile and an optional honeypot to block spam bots effectively |
Thu Dec 26 2024 03:53:46 GMT+0000 (Coordinated Universal Time) |
cm54shdko000009mggqio8er8 |
how-i-made-a-contact-form-bot-killer-using-cloudflare-turnstile-captcha-challenge-plus-a-bonus-honeypot |
cloudflare, captcha, honeypot, turnstile |
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.
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.
![](https://cdn.hashnode.com/res/hashnode/image/upload/v1735182360074/08723369-e9e0-4bfd-b50a-3a0eb125afc0.png align="center")
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:
-
Component mounts
-
Injects Turnstile script tag
-
Loads Turnstile API
-
Renders widget
-
Returns widget ID
-
Calls onWidgetId callback (if used in parent component)
/* ... Form rendering ... */
<Turnstile
theme="dark" // sets the theme
success={status === 'success'} // indicates success, removal of widget
{/* Other props/styling */}
/>
/*... Submission rendering ...*/
// 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);
![](https://cdn.hashnode.com/res/hashnode/image/upload/v1735182896718/48f808d9-2336-4cf8-bbaf-f78c49bfdba1.png align="center")
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;
![](https://cdn.hashnode.com/res/hashnode/image/upload/v1735183257318/8ca5d85e-bca0-429f-bca7-e68eae314a67.png align="center")
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.
try {
const result = await verifyTurnstileToken(token);
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 }),
});
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);
}
}
};
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');
}
};
Once the action is successful and complete, the component cleans up after itself.
-
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
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.
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}
/>