Skip to content

Latest commit

 

History

History
268 lines (193 loc) · 10.2 KB

cm54shdko000009mggqio8er8.md

File metadata and controls

268 lines (193 loc) · 10.2 KB
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.

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

![](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:

Mount Phase

  1. Component mounts

  2. Injects Turnstile script tag

  3. Loads Turnstile API

  4. Renders widget

  5. Returns widget ID

  6. 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

![](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;

Part III: The Token Is Submitted for Verification by a Worker

![](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.

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

  1. Component unmounts

  2. Removes script

  3. 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!

  1. Render widget

  2. Receive token

  3. User hits “Submit”

  4. Token sent for verification

  5. Token is verified, then a response is returned

  6. Submission action completes

  7. 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}
            />