05/30/2026 4 min read

Complete Guide to Implementing Cloudflare Turnstile in Drupal

A step-by-step guide to integrating Cloudflare Turnstile into Drupal using the contributed module or a manual implementation — no CAPTCHAs required.

Introduction

Cloudflare Turnstile is a user-friendly, privacy-preserving alternative to traditional CAPTCHAs. It provides robust spam and bot protection without forcing legitimate users to solve frustrating puzzles.

This guide covers the core terminology, how to generate your API keys, and two methods for integrating Turnstile into your Drupal site: using the contributed module (recommended) and building a manual integration.

Part 1: Understanding the Terminology

Before implementing Turnstile, it helps to understand how the process works. The implementation requires two distinct phases:

  1. Client-Side Rendering (The Browser): The Turnstile widget must run on the user’s device. The JavaScript evaluates the browser environment and user behavior. If it determines the user is human, it generates a unique string of text called a “token.”
  2. Server-Side Validation (The Backend): Your Drupal server cannot just trust the browser. When the form is submitted, Drupal takes the token and makes a secure, server-to-server API call to Cloudflare. If Cloudflare replies with success: true, Drupal processes the form.

Part 2: Get Your Cloudflare Turnstile Keys

Regardless of which method you choose, you need API keys.

  1. Log in to your Cloudflare Dashboard.
  2. In the left-hand sidebar, navigate to Turnstile.
  3. Click the Add widget button.
  4. Enter a Site Name (for your reference) and add your website’s Domain.
  5. Choose your Widget Mode (the default Managed mode is highly recommended).
  6. Click Create.
  7. Keep this tab open—you will need the Site Key and Secret Key for the steps below.

Part 3: Method A – Using the Drupal Module (Recommended)

This is the fastest and most maintainable way to add Turnstile to a Drupal 10 or 11 site. Turnstile acts as an extension of the standard Drupal CAPTCHA module.

1. Install the Modules

Run the following Composer commands in your project root:

Bash

composer require drupal/captcha
composer require drupal/turnstile

2. Enable the Modules

Use Drush to enable both modules:

Bash

drush en captcha turnstile -y

3. Configure the Keys

  1. The cloudflare turnstile module works with the drupal key module making it easier to handle credentials. Go to Configuration > System > Keys (/admin/config/system/keys)
  2. Create a new key and switch the key type to Authentication (Multivalue) and ensure your JSON includes “site_key” and “secret_key”, for example:
    { “site_key”: “0x000…”, “secret_key”: “0x000…” }
  3. In your Drupal admin, go to Configuration > People > CAPTCHA module settings > Turnstile (/admin/config/people/captcha/turnstile).
  4. Select the key you created for Cloudflare Turnstile
  5. Click Save configuration.

4. Apply Turnstile to Your Forms

  1. Go to Configuration > People > CAPTCHA module settings (/admin/config/people/captcha).
  2. Change the Challenge type to Turnstile.
  3. Click Save configuration.
  4. Navigate to Captcha Points Configuration > People > CAPTCHA module settings > Captcha Points. For every form you want to protect (e.g., user_login_form, contact_message_feedback_form, webform_submission_turnstile_test_form_add_form) enable captcha and specifically turnstile

Part 4: Method B – Manual Integration (Custom Module)

If you prefer not to use contributed modules, you can integrate Turnstile manually by writing a small custom module.

Module Directory and Info File

Navigate to your Drupal installation’s custom module directory and create a home for your new extension:

Directory: docroot/modules/custom/custom_turnstile/

Inside this folder, create the module’s primary definition file.

File: custom_turnstile.info.yml

name: 'Custom Cloudflare Turnstile'
type: module
description: 'Integrates Cloudflare Turnstile bot protection into Drupal forms.'
package: Custom
core_version_requirement: ^10 || ^11
dependencies:

Define Configuration Schema

To store our backend authentication keys cleanly and safely within Drupal’s configuration management layer,
we must supply a schema layout.

File: config/schema/custom_turnstile.schema.yml

custom_turnstile.settings:
  type: config_object
  label: 'Custom Cloudflare Turnstile settings'
  mapping:
    site_key:
      type: string
      label: 'Cloudflare Turnstile Site Key'
    secret_key:
      type: string
      label: 'Cloudflare Turnstile Secret Key'

Create the Administrative Settings UI

We need a custom settings form that allows administrators to cleanly input their Turnstile credentials provided
inside the Cloudflare Dashboard console.

File: src/Form/TurnstileSettingsForm.php

<?php

namespace Drupal\custom_turnstile\Form;

use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;

class TurnstileSettingsForm extends ConfigFormBase {

  protected function getEditableConfigNames() {
    return ['custom_turnstile.settings'];
  }

  public function getFormId() {
    return 'custom_turnstile_settings_form';
  }

  public function buildForm(array $form, FormStateInterface $form_state) {
    $config = $this->config('custom_turnstile.settings');

    $form['site_key'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Site Key'),
      '#default_value' => $config->get('site_key'),
      '#required' => TRUE,
    ];

    $form['secret_key'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Secret Key'),
      '#default_value' => $config->get('secret_key'),
      '#required' => TRUE,
    ];

    return parent::buildForm($form, $form_state);
  }

  public function submitForm(array &$form, FormStateInterface $form_state) {
    $this->config('custom_turnstile.settings')
      ->set('site_key', $form_state->getValue('site_key'))
      ->set('secret_key', $form_state->getValue('secret_key'))
      ->save();

    parent::submitForm($form, $form_state);
  }
}

To expose this administrative form inside Drupal’s routing architecture, map a reliable path endpoint:

File: custom_turnstile.routing.yml

custom_turnstile.settings:
  path: '/admin/config/services/turnstile'
  defaults:
    _form: '\Drupal\custom_turnstile\Form\TurnstileSettingsForm'
    _title: 'Cloudflare Turnstile Settings'
  requirements:
    _permission: 'administer site configuration'

Register Cloudflare JavaScript with Asset License

Modern iterations of Drupal require explicit remote metadata licenses when pointing asset pipelines directly at
a remote CDN. Neglecting this mapping produces severe system faults. Create your definitions file
Accordingly:

File: custom_turnstile.libraries.yml

turnstile.api:
  remote: https://developers.cloudflare.com/turnstile
  version: 1.x
  license:
    name: Public Domain
    url: https://developers.cloudflare.com/turnstile
    gpl-compatible: true
  js:
    https://challenges.cloudflare.com/turnstile/v0/api.js: { type: external, attributes: { async: true, defer: true } }

Architect a Reusable Render Element Plugin

By creating a dedicated @FormElement plugin object, we completely avoid inline hardcoding hacks. This
structures the widget cleanly so that any custom form on your site can instantly render and auto-validate
challenges by adding a single line element parameter: ‘#type’ => ‘turnstile’.

File: src/Element/Turnstile.php

<?php

namespace Drupal\custom_turnstile\Element;

use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element\FormElement;

/**
 * Provides a Cloudflare Turnstile form element.
 *
 * @FormElement("turnstile")
 */
class Turnstile extends FormElement {

  /**
   * {@inheritdoc}
   */
  public function getInfo() {
    $class = get_class($this);
    return [
      '#input' => TRUE,
      '#process' => [
        [$class, 'processTurnstile'],
      ],
      '#element_validate' => [
        [$class, 'validateTurnstile'],
      ],
      '#theme_wrappers' => ['form_element'],
    ];
  }

  /**
   * Processes the Turnstile element to render the widget Markup.
   */
  public static function processTurnstile(&$element, FormStateInterface $form_state, &$complete_form) {
    $config = \Drupal::config('custom_turnstile.settings');
    $site_key = $config->get('site_key');

    // Attach external cloudflare script.
    $element['#attached']['library'][] = 'custom_turnstile/turnstile.api';

    // Output the div wrapper that Cloudflare's API detects automatically.
    $element['#markup'] = '<div class="cf-turnstile" data-sitekey="' . htmlspecialchars($site_key, ENT_QUOTES, 'UTF-8') . '"></div>';

    return $element;
  }

  /**
   * Server-side validation of the Turnstile token response.
   */
  public static function validateTurnstile(&$element, FormStateInterface $form_state, &$complete_form) {
    // Cloudflare automatically appends 'cf-turnstile-response' to POST data.
    $user_input = $form_state->getUserInput();
    $token = $user_input['cf-turnstile-response'] ?? '';

    if (empty($token)) {
      $form_state->setError($element, t('Please complete the security challenge.'));
      return;
    }

    $config = \Drupal::config('custom_turnstile.settings');
    $secret_key = $config->get('secret_key');

    // Call Cloudflare Siteverify API.
    try {
      $client = \Drupal::httpClient();
      $response = $client->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
        'form_params' => [
          'secret' => $secret_key,
          'response' => $token,
          'remoteip' => \Drupal::request()->getClientIp(),
        ],
      ]);

      $data = json_decode($response->getBody()->getContents(), TRUE);

      if (empty($data['success'])) {
        $form_state->setError($element, t('Security validation failed. Please try again.'));
      }
    } catch (\Exception $e) {
      \Drupal::logger('custom_turnstile')->error('Turnstile verification error: @message', ['@message' => $e->getMessage()]);
      $form_state->setError($element, t('Unable to validate security challenges at this time.'));
    }
  }
}

Target and Intercept Core Forms

To deploy our element wrapper onto core forms dynamically (such as login pages, user generation screens,
and password recovery setups), implement standard application level form hook interception.

File: custom_turnstile.module

<?php

use Drupal\Core\Form\FormStateInterface;

/**
 * Implements hook_form_alter().
 */
function custom_turnstile_form_alter(&$form, FormStateInterface $form_state, $form_id) {
  // Target forms (e.g., User Login, User Registration, User Password Reset).
  $target_forms = [
    'user_login_form',
    'user_register_form',
    'webform_submission_turnstile_test_form_add_form',
  ];

  if (in_array($form_id, $target_forms)) {
    // Inject our custom Turnstile render element right above the form actions (submit buttons).
    $form['captcha_turnstile'] = [
      '#type' => 'turnstile',
      '#weight' => 90, // Position it right above the submit button
    ];
  }
}

Module Initialization

Execute these terminal commands to initialize configurations and reset system caches:

# Enable the new module via Drush
drush en custom_turnstile -y

# Rebuild definitions to incorporate updates smoothly
drush cr

Post-Activation Steps: Navigate directly to /admin/config/services/turnstile inside your
management window. Plug your respective Cloudflare public and private API credentials in, hit save, and visit
your site’s target forms in an incognito window to verify successful security challenge operations.

Testing Your Implementation

Regardless of the method you choose, always test your forms.

  1. Open an incognito/private browsing window.
  2. Navigate to one of your protected forms.
  3. Verify the Turnstile widget renders correctly (a loading circle followed by a green checkmark).
  4. Submit the form to ensure validation passes.
  5. (For manual setups) Try submitting the form before the widget loads or with JavaScript disabled to ensure your validation properly blocks the submission.