Tổng quan luồng hoạt động
- Trang HTML nhúng widget Turnstile bằng
data-sitekey. - Khi người dùng submit, trình duyệt gửi token (trường
cf-turnstile-response) về server. - Server gọi
POST https://challenges.cloudflare.com/turnstile/v0/siteverifyvớisecret,response, và (tùy chọn)remoteip. - Nếu trả về
success=truethì tiếp tục xử lý (tạo tài khoản, gửi mail…).
Frontend: HTML + JS nhúng Turnstile
<!-- Thay YOUR_TURNSTILE_SITE_KEY bằng site key của bạn -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form id="register-form" method="post" action="/register.php">
<input type="text" name="username" placeholder="Tên đăng nhập" required>
<input type="email" name="email" placeholder="Email" required>
<input type="password" name="password" placeholder="Mật khẩu" minlength="8" required>
<div class="cf-turnstile"
data-sitekey="YOUR_TURNSTILE_SITE_KEY"
data-theme="auto"
data-callback="onTsOk"></div>
<button type="submit" id="btn-submit" disabled>Đăng ký</button>
</form>
<script>
// Khi token hợp lệ, Turnstile gọi callback này
function onTsOk() {
var btn = document.getElementById('btn-submit');
if (btn) btn.disabled = false;
}
</script>
Lưu ý: Turnstile tự chèn input ẩn tên cf-turnstile-response. Bạn không cần tự thêm trường này.
Backend: hàm PHP xác thực Turnstile (dùng được ở mọi dự án)
<?php
/**
* Xác thực token Cloudflare Turnstile.
* @param string $token Giá trị từ trường POST 'cf-turnstile-response'
* @param string $secret Secret key của bạn (KHÔNG để lộ ra client)
* @param string $remoteIp (tùy chọn) IP của client để tăng độ tin cậy
* @param float $minScore (tùy chọn) ngưỡng điểm cho các cấu hình có score (nếu có)
* @return array [ 'ok' => bool, 'reason' => string, 'raw' => array ]
*/
function verify_turnstile(string $token, string $secret, string $remoteIp = '', float $minScore = 0.0): array
{
if ($token === '') {
return ['ok' => false, 'reason' => 'missing_token', 'raw' => []];
}
if ($secret === '') {
return ['ok' => false, 'reason' => 'missing_secret', 'raw' => []];
}
$endpoint = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
$postData = [
'secret' => $secret,
'response' => $token,
];
if ($remoteIp !== '') {
$postData['remoteip'] = $remoteIp;
}
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
CURLOPT_POSTFIELDS => http_build_query($postData, '', '&'),
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$respBody = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($respBody === false) {
return ['ok' => false, 'reason' => 'transport_error:' . $curlErr, 'raw' => []];
}
if ($httpCode !== 200) {
return ['ok' => false, 'reason' => 'bad_http:' . $httpCode, 'raw' => []];
}
$data = json_decode($respBody, true) ?: [];
// $data ví dụ: ['success' => true, 'challenge_ts' => '...', 'hostname' => '...', 'action' => '...', 'cdata' => '...', 'error-codes' => []]
if (empty($data['success'])) {
$reason = 'invalid_token';
if (!empty($data['error-codes']) && is_array($data['error-codes'])) {
$reason .= ':' . implode(',', $data['error-codes']);
}
return ['ok' => false, 'reason' => $reason, 'raw' => $data];
}
// Nếu bạn cấu hình action/cdata ở client, có thể kiểm tra khớp:
// if (($data['action'] ?? '') !== 'register') return ['ok' => false, 'reason' => 'action_mismatch', 'raw' => $data];
// Một số cấu hình có score (không phải lúc nào cũng có). Nếu có thì kiểm tra ngưỡng.
if ($minScore > 0 && isset($data['score']) && is_numeric($data['score']) && (float)$data['score'] < $minScore) {
return ['ok' => false, 'reason' => 'low_score:' . $data['score'], 'raw' => $data];
}
return ['ok' => true, 'reason' => 'ok', 'raw' => $data];
}
Ví dụ xử lý đăng ký đơn giản với hàm trên
<?php
// register.php
// Thay YOUR_TURNSTILE_SECRET_KEY bằng secret key thực của bạn (đặt qua biến môi trường càng tốt)
$TURNSTILE_SECRET = getenv('TURNSTILE_SECRET') ?: 'YOUR_TURNSTILE_SECRET_KEY';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim($_POST['username'] ?? '');
$email = trim($_POST['email'] ?? '');
$password = (string)($_POST['password'] ?? '');
$token = (string)($_POST['cf-turnstile-response'] ?? '');
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
// 1) Kiểm tra đầu vào cơ bản
$errors = [];
if ($username === '' || !preg_match('/^[A-Za-z0-9._-]{3,32}$/', $username)) {
$errors[] = 'Tên đăng nhập không hợp lệ.';
}
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Email không hợp lệ.';
}
if (strlen($password) < 8) {
$errors[] = 'Mật khẩu tối thiểu 8 ký tự.';
}
// 2) Kiểm tra Turnstile
require __DIR__ . '/turnstile.php'; // file chứa hàm verify_turnstile ở trên (hoặc dán trực tiếp)
$ts = verify_turnstile($token, $TURNSTILE_SECRET, $ip, 0.0); // minScore=0.0 (tùy chỉnh)
if (!$ts['ok']) {
$errors[] = 'Xác minh Turnstile thất bại: ' . $ts['reason'];
}
// 3) Nếu không lỗi thì tiếp tục đăng ký (ví dụ chèn DB giả lập)
if (!$errors) {
// TODO: Kiểm tra trùng username/email, băm mật khẩu, lưu DB, gửi email kích hoạt...
// password_hash() để băm mật khẩu:
// $hash = password_hash($password, PASSWORD_DEFAULT);
// Ví dụ phản hồi thành công
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['status' => 'ok', 'message' => 'Tạo tài khoản thành công']);
exit;
}
// 4) Có lỗi: trả JSON lỗi
header('Content-Type: application/json; charset=utf-8', true, 400);
echo json_encode(['status' => 'error', 'errors' => $errors]);
exit;
}
?>
Kiểm thử nhanh
- Chạy một web server cục bộ (vd:
php -S localhost:8000) và truy cập trang form. - Quan sát DevTools → Network để thấy trường
cf-turnstile-responseđược gửi. - Tạm in ra
$ts['raw']khi ở môi trường dev để xem chi tiết phản hồi vàerror-codes.
Thực hành tốt nhất
- Giữ secret key ở biến môi trường, không commit vào repo.
- Kết hợp thêm rate limit theo IP/email và một trường honeypot ẩn để tăng lớp bảo vệ.
- Không cache nội dung chứa token theo người dùng; nếu có CDN, cấu hình bỏ qua cache cho route xử lý POST.
- Nếu dùng chế độ có
action/cdata, hãy kiểm tra khớp ở server để tránh lạm dụng token chéo form.
Câu hỏi thường gặp
Token có hết hạn không? Có. Token Turnstile sống ngắn; đừng tái sử dụng. Nếu form mở lâu, hãy cho phép reset widget khi lỗi.
Có cần gửi remote IP? Không bắt buộc, nhưng có thể giúp tăng độ chính xác đánh giá rủi ro.
Vì sao nhận lỗi transport/bad_http? Thường do firewall, DNS, hoặc server không ra ngoài. Kiểm tra cURL, DNS, cổng 443 và chứng chỉ SSL.
Kết luận
Chỉ với một hàm PHP nhỏ gọn và vài dòng HTML, bạn đã có lớp phòng thủ chống spam gọn nhẹ, không làm phiền người dùng. Từ ví dụ trên, bạn có thể tái sử dụng cho mọi endpoint nhận form mà không cần phụ thuộc vào nền tảng cụ thể.
Bình luận