Published

- 7 min read

Guarding Your Forms with Cloudflare's Turnstile: A Step-By-Step Guide

img of Guarding Your Forms with Cloudflare's Turnstile: A Step-By-Step Guide

Introduction

Securing web forms has become an increasingly pertinent issue in today’s digital landscape. With Cloudflare’s Turnstile, implementing non-interactive captchas to protect forms has never been easier.

This guide will walk you through a step-by-step process of integrating Cloudflare’s Turnstile into your forms for non-interactive captcha protection.

Here is a high-level overview of what is happening behind the scenes:

"turnstile-high-level-overview.png"

Taken from Cloudflare

Getting Started with Cloudflare’s Turnstile

Firstly, you’ll need a Cloudflare account. If you don’t have one, you can easily create a new account. If you’ve already registered, simply log in.

Once logged in, navigate to the Turnstile section via the left-hand navigation bar. Click to add a new site, giving it a descriptive name for ease of management. Next, add your domain - if you’re currently working locally, you can use ’localhost‘.

"turnstile-add-site.png"

At this stage, you’ll need to select ‘Widget Mode’ - this will determine how your captcha behaves. You can choose from:

  • Managed: Cloudflare uses visitor data to determine if an interactive challenge is required.
  • Non-Interactive: Provides a completely non-interactive challenge.
  • Invisible: Offers an invisible challenge that doesn’t require user interaction.

For this guide, we will be focusing on the non-interactive mode.

After completing these steps, you’ll receive a Site Key and a Secret Key.

The Site Key is used on the client side as a unique identifier, while the Secret Key is used on the server side to validate the client’s token.

Implementing on the Client Side with Next.js

With your keys in hand, you’re ready to start integrating Turnstile into your application. We will be using Next.js and creating a custom hook, allowing us to easily plug the captcha into any form.

Below is the custom hook:

import { useCallback, useEffect, useMemo, useState } from 'react'
import Script from 'next/script'

const TURNSTILE_PUBLIC_SITE_KEY = 'YOUR-SITE-KEY'

const useTurnstileCaptcha = () => {
	const [token, setToken] = useState()
	const [widgetId, setWidgetId] = useState()

	const content = useMemo(
		() => (
			<>
				<Script
					src='https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback'
					strategy='lazyOnload'
					defer
					async
				/>
				<div id='cpt-trnstl-container' />
			</>
		),
		[]
	)

	const initializeCaptcha = () => {
		if (window && window?.turnstile) {
			const id = window.turnstile.render('#captcha-container', {
				sitekey: TURNSTILE_PUBLIC_SITE_KEY,
				callback: function (token) {
					setToken(token)
				}
			})
			setWidgetId(id)
		}
	}

	useEffect(() => {
		if (window) {
			window.onloadTurnstileCallback = initializeCaptcha
		}

		return () => {
			window.onloadTurnstileCallback = null
		}
	}, [])

	// Since the Next.js is a single page application
	// the captcha widget will not be re-rendered on route change
	// so we need to issue a re-render manually
	useEffect(() => {
		initializeCaptcha()
	}, [content])

	// This is the function that resets the captcha widget
	// which will give you a new token

	// routing to a new page will create a new widget with new widgetId
	// it is crucial to call the reset with the widgetId
	const resetWidget = useCallback(() => {
		if (window && window?.turnstile) {
			window.turnstile.reset(widgetId)
		}
	}, [widgetId])

	const validateCaptcha = useCallback(async () => {
		try {
			// This is the function that sends the token to your captcha verification service
			const verificationResponse = await fetch('YOUR-CAPTCHA-VERIFY-SERVICE-URL', {
				method: 'POST',
				headers: {
					'Content-Type': 'application/json'
				},
				body: JSON.stringify({
					token
				})
			})

			const verificationResponseJson = await verificationResponse.json()

			return verificationResponseJson?.data
		} catch (error) {
			return false
		}
	}, [token])

	return { content, token, validateCaptcha, resetWidget }
}

export default useTurnstileCaptcha

The hook is ready, let’s implement the server side to validate the captcha token.

Implementing on the Server Side with Cloudflare Workers

Having set up the client-side implementation, it’s time to create a server-side service that can validate the captcha. Cloudflare Workers offer a seamless way to accomplish this.

To create a new project with Cloudflare Workers, we’ll use the Wrangler CLI. Wrangler is a command-line tool for interacting with Cloudflare’s developer products. To install or update Wrangler, follow the instructions here.

Here’s an overview of how to set up the worker.

This is the entry point. The worker.js:

import apiRouter from './router'

export default {
	async fetch(request, env, ctx) {
		return apiRouter.handle(request, env)
	}
}

And, router.js:

import { Router } from 'itty-router'
import { verifyCaptcha } from './services/captcha'
import { generateCorsHeaders } from './corsHelper'

const router = Router()

// Add any other allowed origins here
const ALLOWED_ORIGINS = ['http://localhost:3000']

// Handle CORS preflight requests
router.options('*', (request) => {
	const origin = request.headers.get('Origin')

	if (ALLOWED_ORIGINS.includes(origin)) {
		return new Response(null, {
			headers: generateCorsHeaders(origin)
		})
	}

	return new Response(null, {
		headers: {
			Allow: 'OPTIONS'
		},
		status: 405
	})
})

// Handle captcha verification requests
router.post('/verify', async (request, env) => {
	const content = await request.json()
	const origin = request.headers.get('Origin')
	const ip = request.headers.get('CF-Connecting-IP')

	// env passed in from worker.js
	const secretKey = env.CAPTCHA_SECRET_KEY
	const token = content.token

	const verificationResult = await verifyCaptcha({ token, ip, secretKey })

	return new Response(JSON.stringify({ data: verificationResult }), {
		headers: generateCorsHeaders(origin)
	})
})

// 404 for everything else
router.all('*', () => new Response('Not Found.', { status: 404 }))

export default router

And, verifyCaptcha.js service:

const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'

export const verifyCaptcha = async ({ token, ip, secretKey }) => {
	const formData = new FormData()

	formData.append('secret', secretKey)
	formData.append('response', token)
	formData.append('remoteip', ip)

	try {
		const result = await fetch(url, {
			body: formData,
			method: 'POST'
		})

		const outcome = await result.json()

		if (!outcome.success) {
			return false
		}

		return true
	} catch (error) {
		return false
	}
}

And, the corsHelper.js file:

export const generateCorsHeaders = (origin) => {
	const corsHeaders = {
		'Access-Control-Allow-Methods': 'POST,OPTIONS',
		'Access-Control-Max-Age': '86400',
		'Access-Control-Allow-Headers': 'Content-Type',
		'Access-Control-Allow-Credentials': 'true'
	}

	if (origin) {
		corsHeaders['Access-Control-Allow-Origin'] = origin
	}

	return corsHeaders
}

As the final step, we need to set the Secret Key. On the local development environment, we need to create a special file: .dev.vars:

    CAPTCHA_SECRET_KEY=PASTE-YOUR-KEY-HERE

You can deploy the worker to Cloudflare with npm run deploy the command.

For production, you can set the key from the Cloudflare console. First, click on Workers & Pages tab and select your deployed worker. Then select Settings from the tab and finally pick Variables. Then, add your variable:

"worker-production-env.png"

This will re-deploy your worker automatically to use the latest environment values.

Integrating into Your Form

With both the client and server side ready, you can now integrate the non-interactive captcha into your form.

Below is a simple example of a login form utilizing our custom hook:

import React from 'react'

const LoginForm = () => {
	const { content: captchaContent, validateCaptcha, resetWidget } = useTurnstileCaptcha()

	const handleFormSubmit = async (event) => {
		event.preventDefault()

		const captchaValidated = await validateCaptcha()

		if (!captchaValidated) {
			// Notice, we used invisible captcha, so we need to tell user
			// about the captcha validation failure
			alert('Captcha validation failed. Please try again.')

			// Reset captcha widget so that user can try again
			resetWidget()

			return
		}

		// User is validated, so we can proceed with form submission

		const { username, password } = event.target.elements

		// Do something with username and password
		// ...
	}

	return (
		<form>
			<input type='text' name='username' />
			<input type='password' name='password' />
			{captchaContent}
			<button type='submit' onClick={handleFormSubmit}>
				Submit
			</button>
		</form>
	)
}

export default LoginForm

FAQs

What is Cloudflare’s Turnstile?

Turnstile is a service provided by Cloudflare that offers captcha solutions to protect forms from spam and abuse. It offers different modes, including managed, non-interactive, and invisible captchas.

Why use non-interactive captchas?

Non-interactive captchas offer a seamless user experience as they do not require users to solve puzzles or identify images, while still providing protection against bots.

How does captcha validation work?

The captcha generates a unique token on the client side. This token is sent to the server side for validation, and if the validation is successful, it allows the form submission to proceed.

Conclusion

There you have it! By following this guide, you’ve learned how to implement non-interactive captchas into your forms using Cloudflare’s Turnstile.

Not only does this provide an additional layer of security for your forms, but it also improves the user experience by not requiring them to solve captchas manually.