If you already know what Cloudflare Turnstile is and why it makes sense to adopt it, this article covers the complete technical process for integrating it into PrestaShop 8. No shortcuts that hide what’s happening underneath.
Prerequisites
- PrestaShop 8.x with access to the Back Office and the server
- Free Cloudflare account — your domain doesn’t need to be using Cloudflare’s nameservers to use Turnstile
- PHP 8.1+ (PS8 already requires it)
- SSH or FTP access to the server to upload the module
Step 1: Create the Turnstile site in Cloudflare
Log in to dash.cloudflare.com → Turnstile section in the left menu.
Click Add site and fill in:
- Site name: anything you want (for your reference only)
- Domain: your exact domain (
yourstore.com). Cloudflare will only accept verifications coming from that domain, which prevents others from reusing your keys - Widget type: choose from three modes (covered below)
Once the site is created, you get two keys:
– Site Key (public): goes in the frontend, in the HTML
– Secret Key (private): goes on the server, never in the frontend
Save them. You’ll enter them in the module configuration panel.
Step 2: Minimum structure of a PS8 module
A PrestaShop 8 module integrating Turnstile needs at minimum this structure:
zeyvro_turnstile/
├── zeyvro_turnstile.php ← main class
├── config.xml ← module metadata
├── index.php ← direct access protection
├── views/
│ └── templates/
│ └── front/
│ └── turnstile_widget.tpl ← widget template
└── sql/
├── install.sql
└── uninstall.sql
The main file defines the class, which inherits from Module:
<?php
if (!defined('_PS_VERSION_')) exit;
class ZeyvroTurnstile extends Module
{
public function __construct()
{
$this->name = 'zeyvro_turnstile';
$this->tab = 'front_office_features';
$this->version = '1.0.3';
$this->author = 'Zeyvro';
$this->need_instance = 0;
$this->ps_versions_compliancy = [
'min' => '8.0.0',
'max' => _PS_VERSION_,
];
parent::__construct();
$this->displayName = $this->trans('Zeyvro Turnstile', [], 'Modules.Zeyvroturnstile.Admin');
$this->description = $this->trans(
'Cloudflare Turnstile anti-spam for PrestaShop contact forms.',
[],
'Modules.Zeyvroturnstile.Admin'
);
}
}
Step 3: The required hooks
For PrestaShop 8’s native contact form you need to register these hooks in the install() method:
public function install(): bool
{
return parent::install()
&& $this->registerHook('displayHeader')
&& $this->registerHook('displayBeforeBodyClosingTag')
&& $this->registerHook('actionFrontControllerSetMedia');
}
Why these three and not actionContactFormSubmitCaptcha?
actionContactFormSubmitCaptcha exists but its behavior varies between PS8 versions and isn’t available in all installations with custom contact modules. The more robust approach is:
displayHeader→ load the Turnstile script only on the contact pagedisplayBeforeBodyClosingTag→ inject the widget at the end of the bodyactionFrontControllerSetMedia→ intercept the submit and validate the token server-side before the core processes the message
Step 4: Load the Turnstile script in the frontend
In the hookDisplayHeader() method:
public function hookDisplayHeader(array $params): string
{
// Only on the contact page
$controller = $this->context->controller;
if (!($controller instanceof ContactController)) {
return '';
}
if (!Configuration::get('ZEYVRO_TURNSTILE_ENABLED')) {
return '';
}
// Enqueue the official Turnstile script
$this->context->controller->registerJavascript(
'cloudflare-turnstile',
'https://challenges.cloudflare.com/turnstile/v0/api.js',
[
'server' => 'remote',
'position' => 'head',
'attributes' => 'defer',
'priority' => 200,
]
);
return '';
}
Step 5: Render the widget in the form
In hookDisplayBeforeBodyClosingTag(), we render the widget and inject it into the form via JavaScript. This approach avoids having to override the contact form template:
public function hookDisplayBeforeBodyClosingTag(array $params): string
{
$controller = $this->context->controller;
if (!($controller instanceof ContactController)) {
return '';
}
if (!Configuration::get('ZEYVRO_TURNSTILE_ENABLED')) {
return '';
}
$siteKey = Configuration::get('ZEYVRO_TURNSTILE_SITE_KEY');
$mode = Configuration::get('ZEYVRO_TURNSTILE_MODE') ?: 'managed';
$this->context->smarty->assign([
'zb_site_key' => $siteKey,
'zb_widget_mode' => $mode,
]);
return $this->display(__FILE__, 'views/templates/front/turnstile_widget.tpl');
}
The turnstile_widget.tpl template:
<script>
document.addEventListener('DOMContentLoaded', function() {
var form = document.querySelector('#contact-form form, form#contact_form');
if (!form) return;
// Create widget container
var container = document.createElement('div');
container.id = 'zb-turnstile-container';
container.setAttribute('data-sitekey', '{$zb_site_key|escape:"html":"UTF-8"}');
container.setAttribute('data-theme', 'auto');
{if $zb_widget_mode === 'invisible'}
container.setAttribute('data-size', 'invisible');
{/if}
// Insert before the submit button
var submitBtn = form.querySelector('[type="submit"]');
if (submitBtn) {
form.insertBefore(container, submitBtn);
} else {
form.appendChild(container);
}
// Explicitly render the widget
if (typeof turnstile !== 'undefined') {
turnstile.render('#zb-turnstile-container', {
sitekey: '{$zb_site_key|escape:"html":"UTF-8"}',
theme: 'auto',
});
}
});
</script>
Step 6: Server-side validation
This is the critical part. The token generated by the widget on the frontend has zero value if you don’t verify it against the Cloudflare API on the server. Without this step, an attacker can simply omit the token and the form will continue to be processed.
In hookActionFrontControllerSetMedia():
public function hookActionFrontControllerSetMedia(array $params): void
{
$controller = $this->context->controller;
if (!($controller instanceof ContactController)) {
return;
}
if (!Configuration::get('ZEYVRO_TURNSTILE_ENABLED')) {
return;
}
// Validate only on POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
return;
}
$token = $_POST['cf-turnstile-response'] ?? '';
if (empty($token)) {
$this->blockRequest('Token missing');
return;
}
$result = $this->verifyToken($token);
if (!$result['success']) {
$errorCodes = implode(', ', $result['error-codes'] ?? []);
if (Configuration::get('ZEYVRO_TURNSTILE_ACTION_ON_FAIL') === 'log_only') {
$this->logAttempt($token, false, $errorCodes);
} else {
$this->blockRequest('Verification failed: ' . $errorCodes);
}
} else {
$this->logAttempt($token, true, '');
}
}
private function verifyToken(string $token): array
{
$secretKey = Configuration::get('ZEYVRO_TURNSTILE_SECRET_KEY');
$timeout = (int) Configuration::get('ZEYVRO_TURNSTILE_API_TIMEOUT') ?: 5;
$ch = curl_init('https://challenges.cloudflare.com/turnstile/v0/siteverify');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'secret' => $secretKey,
'response' => $token,
'remoteip' => $_SERVER['REMOTE_ADDR'] ?? '',
]),
CURLOPT_TIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => true,
]);
$response = curl_exec($ch);
$errno = curl_errno($ch);
curl_close($ch);
if ($errno || $response === false) {
// Timeout or network error: decision based on configuration
// Default: let through (fail open) to avoid blocking legitimate clients
return ['success' => true];
}
return json_decode($response, true) ?? ['success' => false, 'error-codes' => ['json-decode-error']];
}
Step 7: Error handling
The Cloudflare API returns specific error codes when verification fails:
| Code | Meaning | Recommended action |
|---|---|---|
missing-input-secret |
Secret Key not sent | Review configuration |
invalid-input-secret |
Incorrect Secret Key | Regenerate in Cloudflare |
missing-input-response |
No token received from frontend | Widget JS didn’t execute |
invalid-input-response |
Malformed or expired token | Bot or user with JS blocked |
timeout-or-duplicate |
Token already used or expired (>5 min) | Normal in long sessions |
invalid-widget-id |
Site Key doesn’t match | Review configuration |
hostname-mismatch |
Token came from a different domain | Possible token reuse attack |
The hostname-mismatch error is especially relevant: it means someone obtained a valid token on another site and tried to reuse it on yours. Turnstile blocks it automatically.
For timeout-or-duplicate: if you have users who take more than 5 minutes to fill out a form (long support forms, for example), consider adding a widget refresh button or using execution: 'execute' mode, which generates the token right before submit.
Step 8: The three widget modes
Turnstile offers three operating modes configured in the Cloudflare dashboard when creating the site:
Managed (recommended for most cases): Cloudflare automatically decides whether to show a challenge or not. Most legitimate users won’t see anything. An interactive challenge is only presented if the signals are ambiguous.
Non-interactive: Never shows a challenge to the user. Verification is completely silent. Accepts more false negatives (some bots may pass) in exchange for zero friction.
Invisible: Similar to non-interactive but the widget takes up no visual space. The Cloudflare badge doesn’t appear in the form.
For standard e-commerce contact forms, managed is the right balance. Non-interactive is suitable if you have a very technical user base that frequently generates false positives.
What this approach doesn’t cover
Custom login and registration modules. The actionAuthentication hook exists but its timing and available data vary depending on the PS8 version and installed third-party modules. If you’re using an SSO, OAuth, or multi-step registration module, integration requires specific analysis of those hooks.
Custom forms on CMS pages. If you’ve added custom forms on CMS pages via direct HTML, Turnstile doesn’t cover them automatically. You need to inject the widget explicitly into each form.
Product review and ratings modules. PS8’s native product ratings form doesn’t expose submit hooks that allow cleanly intercepting the verification without a template override.
If you want to skip the manual setup, we’ve built a module that implements everything above plus a Back Office configuration interface, attempt logging with IP and user-agent, and control over what to do when the Cloudflare API doesn’t respond. For early access, open a support ticket.
In the next article, we look at a real case: how we went from 850 bot signups per month to 70, with the exact metrics and configuration used.