key
Blog Home
Research & Engineering
Writer

UD

Aug 22, 2023

Secure Forms with Cloudflare Turnstile and Firebase Functions

There are SaaS platforms like MailChimp that provide built-in subscribe, signup, and contact forms with loads of features. But you may want to build your own forms if you want full control of your users’ privacy and the overall user experience. Let us show you a simple way to do this. Note that we take an opinionated approach in this article - using Cloudflare Turnstile and Firebase Functions.

Spam is a major problem online and forms like contact us and signups are the most common targets. Bots can easily provide fake information which can lead to a number of problems, including:

  • Increased spam
  • Damage to your brand’s reputation
  • Wasted time and resources

There are a number of ways to protect your forms from spam like using CAPTCHAs, device type and IP based restrictions, and other such protections. While CAPTCHA is useful and seen all over the web, it can also be a nuisance for legitimate users. We found Turnstile to be a better alternative. It provides a much smoother user experience.

Prerequisites

  • A Cloudflare account with Turnstile configured. Just signup, select Turnstile under the dashboard menu, add a new site, and follow the on-screen instructions. You will need to copy the site key and secret key for use later in the front-end code and back-end code respectively.
  • A firebase account with firestore and functions turned on. You may need a paid plan. Follow these instructions for the full installation and setup.

Build a contact us form

At a very high level, we need to gather the information provided by the visitor, validate it, and then save the information to your backend. Of course, a lot of care should be taken since we accept info from anonymous users.

We will start with the HTML. For the purposes of this article, we will capture 3 pieces of info - name, email, and an arbitrary message.

<div>
  <label>*Name:</label>
  <input id="name"
         required
         placeholder="Your full name"/>
</div>
<div>
  <label>*Email:</label>
  <input id="email"
         required
         type="email"
         placeholder="Your email address"/>
</div>
<div>
  <label>*Message:</label>
  <textarea id="message"
            required                    
            rows="3"/>
</div>
<div id="cf-turnstile"></div>
<div>
  <button id="cancel">Cancel</button>
  <button id="submit" disabled>Submit</button>
</div>

Pretty self explanatory. Just take a mental note about the presence of the div component with id cf-turnstile. We will use this to inject the Turnstile widget.

While there are excellent JavaScript frameworks like Vue.js to handle input bindings, basic validation, event handling etc., we will stick to pure JavaScript.

On a side note, we purposefully omitted styling the components but if you need a recommendation for a CSS library we at UKey love Tailwind.

A light front-end validation before we pass on the info to the back-end wouldn’t hurt:

const inputs = document.querySelectorAll('input[required], textarea[required]');
let valid = true;

for (let i = 0; i < inputs.length; i++) {
  const input = inputs[i];
  if (!input.value || input.value.length < 2) {
    valid = false;
    input.classList.add('border-red-500');
  } else {
    input.classList.remove('border-red-500');
  }
}

// Validate email using simple regex
const email = document.querySelector('#email');
const regex = new RegExp('[a-z0-9]+@[a-z]+\.[a-z]{2,4}');
if (!email || !email.value || !regex.test(email.value)) {
  valid = false;
  email.classList.add('border-red-500');
} else if (email) {
  email.classList.remove('border-red-500');
}

if (!valid) {
  evt.preventDefault();
  return;
}

// Disable submit button
const btnSubmit = document.querySelector('#submit');
btnSubmit.setAttribute('disabled', 'disabled');

Stating the obvious here - there are many ways to validate the user input. You can also rely on the browser to validate the inputs by placing them inside a form and going the traditional form submission route. 

Once the validation is done, we can invoke Turnstile to prevent spam. The first step is to include the Turnstile JavaScript tag.

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onloadTurnstileCallback" async defer></script>

Refer to the official documentation to study all the available options.

Since we are asynchronously loading the script and passing it a callback method to be invoked once Turnstile is loaded, let’s define the callback method:

window.onloadTurnstileCallback = function() {  
  // This is one way to make sure the form is 
  // ready for submission.
  btnSubmit.removeAttribute('disabled');
}

// Back in the validation code, when the 'valid' flag is true,
// call this:
turnstile.render('#cf-turnstile', {
  sitekey: 'xxxxx-your-key',
  callback: function(token) {
    saveMessage({
      name: document.querySelector('#name').value,
      email: email.value,
      message: document.querySelector('#message').value,
      token: token
    });
  },
  'error-callback': function(error) {
    console.error(error);
    btnSubmit.removeAttribute('disabled');
  }
});

Pretty self-explanatory code. When Turnstile succeeds in generating a token, we can pass the token and the contact information to the back-end using a method like this:

async function saveMessage(value) {
  // This is the URL to your firebase function.    
  const url = 'https://xxx-my-function.run.app';
  const result = await fetch(url, {
    body: JSON.stringify(value),
    method: 'POST',
    mode: 'cors',
    cache: 'no-cache',
    headers: {
      "Content-Type": "application/json",      
    }
  });
  const json = await result.json();    
  if (result.status === 200 && json.success) {
    // Show a success message.
  } else {
    // Show an error message.
  }
}

Alrighty, time to build our back-end (firebase functions) that can validate the Turnstile token and save the contact info to a database like the firestore.

Make sure you have firebase functions setup as pointed out in the prerequisites section.

Let’s create a secret so that we can store and securely retrieve the Turnstile secret key. This secret is used to validate the incoming token from the front-end. Run this command at the root of your project folder:

firebase functions:secrets:set TURNSTILE_KEY

When prompted to enter the value, go to your Cloudflare Turnstile settings page, grab the secret key, and paste it in your terminal. You can find more information about configuring firebase functions environment here.

Open the index.js under the functions folder in your favorite editor and define the firebase function that does the heavy lifting for us:

// The Cloud Functions for Firebase SDK to create Cloud Functions and triggers.
const {onRequest} = require("firebase-functions/v2/https");

// The Firebase Admin SDK to access Firestore.
const {initializeApp} = require("firebase-admin/app");
const {getFirestore} = require("firebase-admin/firestore");

initializeApp();

exports.sendMessage = onRequest({ secrets: ["TURNSTILE_KEY"], cors: true }, async (req, res) => {    
  const json = req.body

  // The fields that we want to store in the database.
  const value = {
    name: json.name,
    email: json.email,
    message: json.message
  }
  
  // Validate the value. name, email, and message are required.
  if (!value.name || !value.email || !value.message 
      || value.message.length < 2 || value.name.length < 2 || value.email.length < 2) {
    res.status(400).json({message: "Invalid message."});
    return;
  }

  const regex = new RegExp('[a-z0-9]+@[a-z]+\.[a-z]{2,4}');
  if (!regex.test(value.email)) {
    res.status(400).json({message: "Invalid email."});
    return;
  }

  const isValid = await verifyTurnstile(req, process.env.TURNSTILE_KEY);
  if (!isValid) {
    res.status(400).json({message: "Invalid request."});
    return;
  }

  // Push the new message into Firestore using the Firebase Admin SDK.
  const writeResult = await getFirestore()
      .collection("messages")
      .add(value);

  // Send back a message that we've successfully written the message
  res.json({ success: true});
});

Next, we need to define the verifyTurnstile method:

async function verifyTurnstile(req, key) {
 const body = await req.body; 
 const token = body['token'];

 // IP address that maybe actually found in one of the many headers
 let ip = req.headers['CF-Connecting-IP'];
  if (!ip) {
    const ips = req.headers['x-forwarded-for'];
    if (ips) {
      ip = ips.split(',')[0];
    }    
  }

 // Validate the token by calling the "/siteverify" API endpoint.
 let formData = new FormData();
 formData.append('secret', key);
 formData.append('response', token);
 formData.append('remoteip', ip);

 const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
 const result = await fetch(url, {
  body: formData,
  method: 'POST',
 });

 const outcome = await result.json();
 if (outcome.success) {  
    return true;
 }

  return false;
}

Pretty self-explanatory code. Refer to the official documentation for more information on site verify API and options.

There you have it. A secure contact form! Of course, you may want to add triggers to get notified when new messages are added to firestore; create a UI to pull these messages and so on. And like we said at the beginning of this article, there are pretty good SaaS products to do it all for you.

Happy coding!

Join a global community shopping smarter with UKey!

SIGN UP FOR FREE

© UKey Inc. 2024

key

Made with 🖤 in Austin and Boston

UKey Inc. is a financial technology company, not a bank.