I set up Roundcube webmail on a freshly configured OpenBSD mail server and looked for ways to secure it beyond the classic fail2ban.
After spending some time online, I found the available captcha plugins to be really outdated, so I decided to build a simple one on my own.
I chose Cloudflare Turnstile over Google reCAPTCHA to deliver instant, privacy-first bot protection without frustrating users with image puzzles or tracking cookies.

Requirements
This solution should work on any modern Linux/OpenBSD installation. I specifically tested it with OpenBSD 7.9
General Requirements
- Roundcube Webmail 1.5.x or 1.6.x+
- PHP 8.x with
jsonandcurloropensslstream wrappers enabled. - Cloudflare Turnstile Site Key & Secret Key (obtained for free from Cloudflare Dashboard).
OpenBSD Specific System Requirements
On OpenBSD, php-fpm operates inside the /var/www chroot sandbox. Outbound HTTPS API verification requires:
- Root CA Certificate Bundle:
/var/www/etc/ssl/cert.pem(copied from/etc/ssl/cert.pem). - DNS Resolution:
/var/www/etc/resolv.conf(copied from/etc/resolv.conf). - Hosts File:
/var/www/etc/hosts(copied from/etc/hosts). - cURL Extension: Package
php-curlinstalled and linked in/etc/php-8.3/curl.ini. - URL Fopen:
allow_url_fopen = Onconfigured in/etc/php-8.3.ini.
Installation & Configuration
Plugin Source Code
Create the file <path-to-roundcubemail>/plugins/turnstile/turnstile.php
For OpenBSD the full path will be /var/www/roundcubemail/plugins/turnstile/turnstile.php
<?php
/**
* Cloudflare Turnstile Security Plugin for Roundcube Webmail
* Compatible with OpenBSD(including chroot) and most modern Linux distributions.
*/
class turnstile extends rcube_plugin
{
public function init()
{
$this->add_hook('render_page', array($this, 'render_page'));
$this->add_hook('authenticate', array($this, 'verify_turnstile'));
}
public function render_page($args)
{
if(empty($args['template']) || $args['template'] !== 'login') {
return $args;
}
$rcmail = rcube::get_instance();
$site_key = $rcmail->config->get('turnstile_site_key');
if(empty($site_key)) {
return $args;
}
$script = '<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>';
$widget = '<div style="margin: 15px 0 10px 0;" class="cf-turnstile" data-sitekey="' . htmlspecialchars($site_key) . '"></div>';
// Inject script tag into <head>
if(strpos($args['content'], '</head>') !== false) {
$args['content'] = str_replace('</head>', $script . '</head>', $args['content']);
}
// Inject widget before submit button or before </form>
if(preg_match('/(<button[^>]*type="submit"[^>]*>)/i', $args['content'])) {
$args['content'] = preg_replace('/(<button[^>]*type="submit"[^>]*>)/i', $widget . '$1', $args['content'], 1);
} elseif(strpos($args['content'], '</form>') !== false) {
$args['content'] = str_replace('</form>', $widget . '</form>', $args['content']);
} else {
$args['content'] .= $widget;
}
return $args;
}
public function verify_turnstile($args)
{
$rcmail = rcube::get_instance();
$secret_key = $rcmail->config->get('turnstile_secret_key');
if(empty($secret_key)) {
return $args;
}
$token = rcube_utils::get_input_value('cf-turnstile-response', rcube_utils::INPUT_POST);
if(empty($token)) {
$args['abort'] = true;
$args['error'] = 'CAPTCHA verification required. Please complete the security check.';
return $args;
}
$url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
$post_data = http_build_query(array(
'secret' => $secret_key,
'response' => $token,
'remoteip' => rcube_utils::remote_ip()
));
$response = false;
// Detect CA bundle path across OpenBSD and Linux distros
$ca_bundle = null;
foreach(array('/etc/ssl/cert.pem', '/etc/ssl/certs/ca-certificates.crt', '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/ca-bundle.pem') as $path) {
if(file_exists($path)) {
$ca_bundle = $path;
break;
}
}
// 1. Primary verification via cURL
if(function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $post_data);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
if($ca_bundle !== null) {
curl_setopt($ch, CURLOPT_CAINFO, $ca_bundle);
}
$response = curl_exec($ch);
curl_close($ch);
}
// 2. Fallback verification via native PHP stream context
if($response === false) {
$ssl_opts = array(
'verify_peer' => true,
'verify_peer_name' => true
);
if($ca_bundle !== null) {
$ssl_opts['cafile'] = $ca_bundle;
}
$context = stream_context_create(array(
'http' => array(
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => $post_data,
'timeout' => 5
),
'ssl' => $ssl_opts
));
$response = @file_get_contents($url, false, $context);
}
$result = json_decode($response, true);
if(!$result || empty($result['success'])) {
rcube::raise_error(array(
'code' => 500,
'file' => __FILE__,
'line' => __LINE__,
'message' => "Turnstile Verification Failed. Raw Response: '" . var_export($response, true) . "' | Parsed: " . var_export($result, true)
), true, false);
$args['abort'] = true;
$args['error'] = 'Security check failed. Please try logging in again.';
return $args;
}
return $args;
}
}
Roundcube Main Configuration
Edit <path-to-roundcubemail>/config/config.inc.php
Add 'turnstile' to the $config['plugins'] array and define keys:
// Enable the plugin
$config['plugins'] = array('archive', 'zipdownload', 'turnstile');
// Cloudflare Turnstile API Keys
$config['turnstile_site_key'] = 'YOUR_CLOUDFLARE_TURNSTILE_SITE_KEY';
$config['turnstile_secret_key'] = 'YOUR_CLOUDFLARE_TURNSTILE_SECRET_KEY';
Testing & Verification
- Open Webmail in your browser:
https://your-domain.com/. - Inspect the login form: The Cloudflare Turnstile widget will appear bellow the login submit button.
- Submit a login attempt: Check browser DevTools network tab for
POSTpayload containingcf-turnstile-response. - Check logs if an error occurs