-
-
Save DavePodosyan/b4e6f0a261ce5c7ed3b30b0734d56291 to your computer and use it in GitHub Desktop.
| <?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. | |
| * | |
| * | |
| * Note: If you're seeing an empty space where the Turnstile widget should be, | |
| * Elementor’s element cache may be preventing it from rendering properly. | |
| * To fix this: | |
| * • In the Elementor editor, select your Form widget → Advanced tab → set | |
| * “Cache Settings” to **Inactive** (for that form only), or | |
| * • Go to Elementor > Settings > Performance and disable the **Element Cache** option. | |
| * | |
| * | |
| * @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(); | |
| }); |
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.
@DavePodosyan I was able to test with your latest revision 1.1.1, and it works. Your implementation with the elementorModules.frontend.handlers.Base and the switch back to implicit is now much simpler than my MutationObserver approach because the Base.bindEvents is executed on popup form presentation. So far, I've been unable to trip up the turnstile with pop/close/pop etc.
Thank you for your work on this.
Thank you very much for the code! I have a question - if I’m using 3 forms on the same page, do I need to add a Turnstile widget to each form, or is one per page enough?
@IgorCR0000, I think you should add to each form. Thank you!
For those who see an empty space instead of the Turnstile widget, it’s likely due to Elementor’s element caching.
To fix it, you have two options:
- Disable Cache for the Form Widget. In the Elementor editor:
• Select the Form widget
• Go to the Advanced tab
• Scroll to Cache Settings
• Set it to Inactive
- Disable Elementor Cache Globally.
• Elementor -> Settings -> Performance -> Element Cache set to Disabled.
For those looking for the plugin version, I have wrapped this, along with the Hcaptcha version, into a plugin.
⚠️ ⚠️ ⚠️ Elementor Cache Warning⚠️ ⚠️ ⚠️ For those who see an empty space instead of the Turnstile widget, it’s likely due to Elementor’s element caching.
To fix it, you have two options:
- Disable Cache for the Form Widget. In the Elementor editor:
• Select the Form widget
• Go to the Advanced tab
• Scroll to Cache Settings
• Set it to Inactive* Disable Elementor Cache Globally. • Elementor -> Settings -> Performance -> Element Cache set to Disabled.
Thanks for the plugin. Unfortunately, I see this space and disabling cache either via the form or globally hasn't fixed this
@Moshe1010, do you see any errors in the browser console?
There is a plugin version that you can try as well
https://github.com/DavePodosyan/captcha-for-elementor-pro-forms
Yes, this:
–––-
There is a plugin version that you can try as well
https://github.com/DavePodosyan/captcha-for-elementor-pro-forms
This is the one I installed
@Moshe1010, which version of the plugin did you install? Is it v.1.0.10?
Latest. v1.0.10. Downloaded today
@Moshe1010, if you feel comfortable sharing the website URL, please open an issue here and let's discuss https://github.com/DavePodosyan/captcha-for-elementor-pro-forms/issues
@Moshe1010, if you feel comfortable sharing the website URL, please open an issue here and let's discuss https://github.com/DavePodosyan/captcha-for-elementor-pro-forms/issues
Opened:
DavePodosyan/captcha-for-elementor-pro-forms#2
Thanks

I'll repeat the requirements I uncovered and added to the linked discussion:
So, a universal implementation:
explicitmodeMutationObservers to track multiple potential popup forms (as well as pop/close/pop scenario). This should reconnect via therender(callback)in case user interaction occursturnstile.remove()is executed