Skip to content

Instantly share code, notes, and snippets.

@DavePodosyan
Last active April 30, 2025 09:31
Show Gist options
  • Save DavePodosyan/b4e6f0a261ce5c7ed3b30b0734d56291 to your computer and use it in GitHub Desktop.
Save DavePodosyan/b4e6f0a261ce5c7ed3b30b0734d56291 to your computer and use it in GitHub Desktop.
Cloudflare Turnstile Integration for Elementor Forms
<?php
/**
*
* A simple integration of Cloudflare Turnstile with Elementor Forms, following Elementor’s pattern for reCAPTCHA.
*
* Instructions:
* 1. Add this file to your WordPress theme directory.
* 2. Include the file in your theme's `functions.php` file using:
*
* // For child themes:
* require_once get_stylesheet_directory() . '/elementor-form-turnstile-handler.php';
*
* // For parent themes:
* require_once get_template_directory() . '/elementor-form-turnstile-handler.php';
*
* 3. Go to WordPress Dashboard > Elementor > Settings > Integrations > Cloudflare Turnstile
* 4. Enter your Turnstile Site Key and Secret Key.
* 5. Edit your Elementor form:
* - Add a new **Cloudflare Turnstile** field to your form (similar to adding a reCAPTCHA field).
* - Save the form.
*
*
* @author @DavePodosyan
* @version 1.1.1
*
* * Changelog:
* 1.1.0 - Switched to explicit rendering mode for better popup/modal compatibility
* 1.0.0 - Initial release with basic Turnstile integration mimicking Elementor's reCAPTCHA logic
*
*/
use Elementor\Settings;
use ElementorPro\Core\Utils;
use ElementorPro\Plugin;
if (! defined('ABSPATH')) {
exit; // Exit if accessed directly
}
/**
* Integration with Cloudflare Turnstile
*/
class Turnstile_Handler
{
const OPTION_NAME_SITE_KEY = 'elementor_pro_cf_turnstile_site_key';
const OPTION_NAME_SECRET_KEY = 'elementor_pro_cf_turnstile_secret_key';
protected static function get_turnstile_name()
{
return 'cf_turnstile';
}
public static function get_site_key()
{
return get_option(self::OPTION_NAME_SITE_KEY);
}
public static function get_secret_key()
{
return get_option(self::OPTION_NAME_SECRET_KEY);
}
public static function get_turnstile_type()
{
return 'managed';
}
public static function is_enabled()
{
return static::get_site_key() && static::get_secret_key();
}
public static function get_setup_message()
{
return esc_html__('To use Cloudflare Turnstile, you need to add the API Key and complete the setup process in Dashboard > Elementor > Settings > Integrations > Claudflare Turnstile.', 'elementor-pro');
}
public function register_admin_fields(Settings $settings)
{
$settings->add_section(Settings::TAB_INTEGRATIONS, static::get_turnstile_name(), [
'label' => esc_html__('Cloudflare Turnstile', 'elementor-pro'),
'callback' => function () {
echo sprintf(
/* translators: 1: Link opening tag, 2: Link closing tag. */
esc_html__('%1$sCloudflare Turnstile%2$s is Cloudflare\'s CAPTCHA alternative solution where your users don\'t ever have to solve another puzzle to get to your website, no more stop lights and fire hydrants.', 'elementor-pro'),
'<a href="https://www.cloudflare.com/application-services/products/turnstile/" target="_blank">',
'</a>'
);
},
'fields' => [
'pro_cf_turnstile_site_key' => [
'label' => esc_html__('Site Key', 'elementor-pro'),
'field_args' => [
'type' => 'text',
],
],
'pro_cf_turnstile_secret_key' => [
'label' => esc_html__('Secret Key', 'elementor-pro'),
'field_args' => [
'type' => 'text',
],
],
],
]);
}
public function localize_settings($settings)
{
$settings = array_replace_recursive($settings, [
'forms' => [
static::get_turnstile_name() => [
'enabled' => static::is_enabled(),
'type' => static::get_turnstile_type(),
'site_key' => static::get_site_key(),
'setup_message' => static::get_setup_message(),
],
],
]);
return $settings;
}
protected static function get_script_name()
{
return 'elementor-' . static::get_turnstile_name() . '-api';
}
protected static function get_inline_script_name()
{
return 'elementor-' . static::get_turnstile_name() . '-inline-handler';
}
public function register_scripts()
{
wp_register_script(
static::get_script_name(),
'https://challenges.cloudflare.com/turnstile/v0/api.js',
[],
null,
true
);
wp_register_script(static::get_inline_script_name(), '', ['elementor-frontend'], null, true);
}
public function enqueue_scripts()
{
if (Plugin::elementor()->preview->is_preview_mode()) {
return;
}
wp_enqueue_script(static::get_script_name());
wp_enqueue_script(static::get_inline_script_name());
wp_add_inline_script(
static::get_inline_script_name(),
<<<JS
if (typeof window.ElementorTurnstileHandler === 'undefined') {
class TurnstileHandler extends elementorModules.frontend.handlers.Base {
getDefaultSettings() {
return {
selectors: {
turnstile: '.elementor-cf-turnstile:last',
submit: 'button[type="submit"]'
}
};
}
getDefaultElements() {
const selectors = this.getDefaultSettings().selectors;
const \$turnstile = this.\$element.find(selectors.turnstile);
const \$form = \$turnstile.parents('form');
const \$submit = \$form.find(selectors.submit);
return { \$turnstile, \$form, \$submit };
}
bindEvents() {
this.waitForTurnstile();
}
waitForTurnstile() {
if (window.turnstile && typeof window.turnstile.render === 'function') {
this.renderTurnstile();
} else {
setTimeout(() => this.waitForTurnstile(), 350);
}
}
renderTurnstile() {
const el = this.elements.\$turnstile[0];
if (!el || el.dataset.turnstileRendered === 'true') return;
if (!jQuery(el).is(':visible')) {
setTimeout(() => this.renderTurnstile(), 200);
return;
}
el.dataset.turnstileRendered = 'true';
const sitekey = this.elements.\$turnstile.data('sitekey');
const widgetId = window.turnstile.render(el, {
sitekey: sitekey,
callback: (token) => {
let \$input = this.elements.\$form.find('[name="cf-turnstile-response"]');
if (!\$input.length) {
\$input = jQuery('<input>', {
type: 'hidden',
name: 'cf-turnstile-response'
}).appendTo(this.elements.\$form);
}
\$input.val(token);
}
});
this.elements.\$form.on('reset error', () => {
window.turnstile.reset(widgetId);
});
}
}
window.ElementorTurnstileHandler = TurnstileHandler;
}
jQuery( window ).on( 'elementor/frontend/init', () => {
elementorFrontend.elementsHandler.attachHandler( 'form', window.ElementorTurnstileHandler );
});
JS
);
}
/**
* @param Form_Record $record
* @param Ajax_Handler $ajax_handler
*/
public function validation($record, $ajax_handler)
{
$fields = $record->get_field([
'type' => static::get_turnstile_name(),
]);
if (empty($fields)) {
return;
}
$field = current($fields);
// PHPCS - response protected by recaptcha secret
$recaptcha_response = Utils::_unstable_get_super_global_value($_POST, 'cf-turnstile-response'); // phpcs:ignore WordPress.Security.NonceVerification.Missing
if (empty($recaptcha_response)) {
$ajax_handler->add_error($field['id'], esc_html__('The Captcha field cannot be blank. Please enter a value.', 'elementor-pro'));
return;
}
$recaptcha_errors = [
'missing-input-secret' => esc_html__('The secret parameter is missing.', 'elementor-pro'),
'invalid-input-secret' => esc_html__('The secret parameter is invalid or malformed.', 'elementor-pro'),
'missing-input-response' => esc_html__('The response parameter is missing.', 'elementor-pro'),
'invalid-input-response' => esc_html__('The response parameter is invalid or malformed.', 'elementor-pro'),
];
$recaptcha_secret = static::get_secret_key();
$client_ip = Utils::get_client_ip();
$request = [
'body' => [
'secret' => $recaptcha_secret,
'response' => $recaptcha_response,
'remoteip' => $client_ip,
],
];
$response = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', $request);
$response_code = wp_remote_retrieve_response_code($response);
if (200 !== (int) $response_code) {
/* translators: %d: Response code. */
$ajax_handler->add_error($field['id'], sprintf(esc_html__('Can not connect to the Cloudflare Turnstile server (%d).', 'elementor-pro'), $response_code));
return;
}
$body = wp_remote_retrieve_body($response);
$result = json_decode($body, true);
if (! $this->validate_result($result, $field)) {
$message = esc_html__('Invalid form, Cloudflare Turnstile validation failed.', 'elementor-pro');
if (isset($result['error-codes'])) {
$result_errors = array_flip($result['error-codes']);
foreach ($recaptcha_errors as $error_key => $error_desc) {
if (isset($result_errors[$error_key])) {
$message = $recaptcha_errors[$error_key];
break;
}
}
}
$this->add_error($ajax_handler, $field, $message);
}
// If success - remove the field form list (don't send it in emails and etc )
$record->remove_field($field['id']);
}
/**
* @param Ajax_Handler $ajax_handler
* @param $field
* @param $message
*/
protected function add_error($ajax_handler, $field, $message)
{
$ajax_handler->add_error($field['id'], $message);
}
protected function validate_result($result, $field)
{
if (! $result['success']) {
return false;
}
return true;
}
/**
* @param $item
* @param $item_index
* @param $widget Widget_Base
*/
public function render_field($item, $item_index, $widget)
{
$recaptcha_html = '<div class="elementor-field" id="form-field-' . $item['custom_id'] . '">';
$recaptcha_name = static::get_turnstile_name();
if (static::is_enabled()) {
$this->enqueue_scripts();
$this->add_render_attributes($item, $item_index, $widget);
$recaptcha_html .= '<div ' . $widget->get_render_attribute_string($recaptcha_name . $item_index) . ' style="min-height:65px"></div><style>.elementor-cf-turnstile > div {display:flex;}</style>';
} elseif (current_user_can('manage_options')) {
$recaptcha_html .= '<div class="elementor-alert elementor-alert-info">';
$recaptcha_html .= static::get_setup_message();
$recaptcha_html .= '</div>';
}
$recaptcha_html .= '</div>';
// PHPCS - It's all escaped
echo $recaptcha_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* @param $item
* @param $item_index
* @param $widget Widget_Base
*/
protected function add_render_attributes($item, $item_index, $widget)
{
$recaptcha_name = static::get_turnstile_name();
$widget->add_render_attribute([
$recaptcha_name . $item_index => [
'class' => 'elementor-cf-turnstile',
'data-sitekey' => static::get_site_key(),
'data-type' => static::get_turnstile_type(),
],
]);
$this->add_version_specific_render_attributes($item, $item_index, $widget);
}
/**
* @param $item
* @param $item_index
* @param $widget Widget_Base
*/
protected function add_version_specific_render_attributes($item, $item_index, $widget)
{
$recaptcha_name = static::get_turnstile_name();
$widget->add_render_attribute($recaptcha_name . $item_index, [
'data-theme' => 'light',
'data-size' => 'flexible',
]);
}
public function add_field_type($field_types)
{
$field_types['cf_turnstile'] = esc_html__('Cloudflare Turnstile', 'elementor-pro');
return $field_types;
}
public function filter_field_item($item)
{
if (static::get_turnstile_name() === $item['field_type']) {
$item['field_label'] = false;
}
return $item;
}
public function __construct()
{
$this->register_scripts();
add_filter('elementor_pro/forms/field_types', [$this, 'add_field_type']);
add_action('elementor_pro/forms/render_field/' . static::get_turnstile_name(), [$this, 'render_field'], 10, 3);
add_filter('elementor_pro/forms/render/item', [$this, 'filter_field_item']);
add_filter('elementor_pro/editor/localize_settings', [$this, 'localize_settings']);
if (static::is_enabled()) {
add_action('elementor_pro/forms/validation', [$this, 'validation'], 10, 2);
add_action('elementor/preview/enqueue_scripts', [$this, 'enqueue_scripts']);
}
if (is_admin()) {
add_action('elementor/admin/after_create_settings/' . Settings::PAGE_ID, [$this, 'register_admin_fields']);
}
}
}
add_action('elementor/init', function () {
new Turnstile_Handler();
});
@DavePodosyan
Copy link
Author

Try to pull this to elementor github.

@nicocart, Unfortunately I can’t submit a pull request since this is part of the Pro plugin and they don’t have a public repository for the Pro version.

@Basilou38
Copy link

@DavePodosyan

Can the PHP file be integrated into a mu-plugins ? In this case, no need to have :
require_once get_stylesheet_directory() . '/elementor-form-turnstile-handler.php';

@DavePodosyan
Copy link
Author

@Basilou38

It is really up to you. If you throw this file in your mu-plugins/ folder, it will work just the same.

@DavePodosyan
Copy link
Author

🚀 Update Notice – v1.1.0

I’ve updated the integration to use Cloudflare Turnstile’s explicit render mode instead of auto-rendering.

Why?
➡️ To support forms inside popups, modals, and dynamically loaded content, just like Elementor’s native reCAPTCHA behavior.

Big thanks to everyone who gave feedback 🙏

@rosskevin
Copy link

@DavePodosyan here is a block of code based on your original, that handles repeated popups (as described here https://github.com/orgs/elementor/discussions/25671#discussioncomment-12948651). This would be better integrated like your 1.1 style vs injecting this script potentially several times. I'm not well versed in Elementor or WP, but I am with javascript.

If you are operating in the Elementor js realm, then perhaps there is an easier way to watch popups/check than a MutationObserver. Certainly, it appears we have to ensure visibility, and I do so by using the id, navigating to the closest form and checking it's computed visibility.

  public function render_field($item, $item_index, $widget)
  {
    $id = $this->get_id($item);
    $this->log->debug('render_field ' . $id);

    $recaptcha_name = static::get_turnstile_name();
    $siteKey = static::get_site_key();

    $recaptcha_html = <<<HTML
      <div class="elementor-field" id="form-field-{$id}">
    HTML;

    if (static::is_enabled()) {
      $this->enqueue_scripts();
      $this->add_render_attributes($item, $item_index, $widget);

      $render_attrs = $widget->get_render_attribute_string($recaptcha_name . $item_index);

      $recaptcha_html .= <<<HTML
        <div {$render_attrs}></div>
        <script>
          document.addEventListener('DOMContentLoaded', function() {
            // init tracking of turnstile renders so they can be removed before re-rendering
            if (!window.__turnstileRendered) {
              window.__turnstileRendered = {};
            }

            const id = "{$id}";
            // enforce the use of an id different from turnstile
            if(id == "turnstile") {
              throw new Error("[TurnstileField] id cannot be 'turnstile'");
            }

            function isVisible() {
              const el = document.getElementById(id);
              if (!el){ 
                return false;
              }

              // must check the visibility of something with dimensions, because our initial div is empty.
              const form = el.closest('form');
              if (!form) return false;

              const rect = form.getBoundingClientRect();
              const style = window.getComputedStyle(form);
              return (
                rect.width > 0 &&
                rect.height > 0 &&
                style.visibility !== 'hidden' &&
                style.display !== 'none'
              );
            }

            function isInDarkForm() {
              const el = document.getElementById(id);
              if (!el) return false;

              const form = el.closest('form');
              if (!form) return false;

              let current = form.parentElement;
              let levels = 0;

              while (current && levels < 3) {
                if (current.classList && current.classList.contains('dark-form')) {
                  return true;
                }
                current = current.parentElement;
                levels++;
              }

              return false;
            }            

            function shouldRender(){
              const el = document.getElementById(id);
              if (!el) {
                debug('[TurnstileField] element not found: {$id}');
                return false;
              }
              if(el.hasAttribute('data-initialized')) {
                debug('[TurnstileField] element already rendered: {$id}');
                return false;
              }
              return isVisible()
            }

            function tryRender(callback) {
              if(shouldRender()) {
                // check page state, if this was rendered before, remove id before re-rendering
                if (window.__turnstileRendered[id]) {
                  debug('[TurnstileField] previously rendered: {$id}, removing before re-rendering');
                  turnstile.remove(id);
                }
                window.__turnstileRendered[id] = true;

                // set the element state to be initialized.  Track this separately from page state becasue it can get wiped on pop/close/pop
                const el = document.getElementById(id);
                el.setAttribute('data-initialized', 'true');

                // render the turnstile
                debug('[TurnstileField] render: {$id}');
                turnstile.render("#{$id}", {
                  sitekey: "{$siteKey}",
                  theme: isInDarkForm() ? "dark" : "light",
                  size: "flexible",
                  appearance: "interaction-only", // only display when interaction is necessary
                  callback: function(token) {
                    debug('[TurnstileField] Challenge Success ' + token);
                    if(callback) {
                      callback(token);
                    }
                  }
                });
                return true;
              }
              debug('[TurnstileField] not visible: {$id}');
              return false;
            }

            // Render the Turnstile field for any that are currently present and visible
            tryRender();

            // Delay a short bit after adding to allow the page to settle, then connect the observer
            //   that will watch for new forms becoming visible e.g. popups
            setTimeout(() => {
              debug('[TurnstileField] Setting up MutationObserver for {$id}');
              const observer = new MutationObserver(() => {
                if(shouldRender()){
                  debug('[TurnstileField] MutationObserver fired for {$id}, disconnecting.');
                  observer.disconnect();
                  // move this render attempt to the back of the render queue
                  setTimeout(() => {
                    tryRender(() => {
                      // reconnect the observer to watch for new forms or pop/close/pop scenarios
                      debug('[TurnstileField] after render attempt, MutationObserver for {$id}, reconnecting to continue watching.');
                      observer.observe(document.body, {
                        childList: true,
                      });
                    });
                  }, 50);
                }
              });

              // initial connection of observer
              observer.observe(document.body, {
                childList: true,
              });
            }, 750)
          });
        </script>
      HTML;
    } elseif (current_user_can('manage_options')) {
      $setup_msg = static::get_setup_message();
      $recaptcha_html .= <<<HTML
        <div class="elementor-alert elementor-alert-info">
          {$setup_msg}
        </div>
      HTML;
    }

    $recaptcha_html .= "</div>";

    // PHPCS - It's all escaped
    echo $recaptcha_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
  }

  /**
   * @param $item
   * @param $item_index
   * @param $widget Widget_Base
   */
  protected function add_render_attributes($item, $item_index, $widget)
  {
    $this->log->debug('add_render_attributes');
    $recaptcha_name = static::get_turnstile_name();

    $widget->add_render_attribute([
      $recaptcha_name . $item_index => [
        'id' => $this->get_id($item),
      ],
    ]);
  }

@rosskevin
Copy link

rosskevin commented Apr 25, 2025

Oh, yes, this removes a console warning from cloudflare ... use null for the version arg:

    wp_register_script($script_name, 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit', array(), null, true);

@DavePodosyan
Copy link
Author

@rosskevin Thanks for sharing your approach!

My goal with this integration was not to invent new behavior, but to stay very close to Elementor’s own reCAPTCHA implementation, just adapted for Turnstile.

Since Elementor themselves don’t use MutationObservers for reCAPTCHA either, I’m trying to stay lightweight and not overcomplicate things unnecessarily.

Definitely appreciate your more advanced example though.

@DavePodosyan
Copy link
Author

Oh, yes, this removes a console warning from cloudflare ... use null for the version arg:

Updated the gist 🙌🏻

@rosskevin
Copy link

rosskevin commented Apr 25, 2025

This isn't new behavior, it's equivalent behavior (I suspect). They may not need MutationObservers for recaptcha because I'm guessing under the hood recaptcha is working differently. The fact is that Turnstile doesn't work with non-visible elements as has been noted in other frameworks and the cause of [Cloudflare Turnstile] Error: 300030 and Turnstile Widget seem to have hung.

So, if you aren't going to integrate the MutationObserver approach, I suggest you NOTE in your doc header that this will not work for forms in popups.

@DavePodosyan
Copy link
Author

@rosskevin
Just to be sure we’re on the same page:

Have you actually tested the updated v1.1.0 version (where I switched to explicit rendering, and only initialize when the element is visible)? And it did not work for you?

If you’re seeing a failure with the latest version, I’d be interested to know the steps so I can reproduce it, otherwise I believe the current structure handles the pop-up cases well without unnecessary complexity.

@rosskevin
Copy link

@DavePodosyan I did not test with your version, I tested with mine. The first thing I did was switch to explicit rendering and the error still occurred (our main form is hidden in a popup).

If you run explicit rendering on a page that has a form in a modal/popup, the HTML element is in the DOM, but it is not visible. Implement one, load the page, wait (don't use the popup), and you will receive error 300030.

This is a limitation imposed by turnstile for some reason, but apparently a well known one. So while you want your implementation to mirror Elementor's recaptcha, that seems like a goal not worth seeking. What seems worth seeking is equivalent functionality.

To sprinkle further pain: pop the form, close the form, pop the form again - more errors. The code I have provided solves all of these cases. It requires explicit rendering, tracking initialization state of the element, and in the case of pop/close/pop executing a turnstile.remove then turnstile.render.

TL;DR I have tested this extensively over the past few days.

@rosskevin
Copy link

I'll repeat the requirements I uncovered and added to the linked discussion:

So, a universal implementation:

  • needs to use turnstile explicit mode
  • needs to reconnect MutationObservers to track multiple potential popup forms (as well as pop/close/pop scenario). This should reconnect via the render(callback) in case user interaction occurs
  • needs to track init/rendered state on the html element (pop/close/pop scenario)
  • needs to track init/rendered id's on page state (memory) so that before re-render, a turnstile.remove() is executed

@nicocart
Copy link

@DavePodosyan

Can the PHP file be integrated into a mu-plugins ? In this case, no need to have : require_once get_stylesheet_directory() . '/elementor-form-turnstile-handler.php';

I simply add them into code snippets plugin.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment