Cloudflare Turnstile ile CAPTCHA’ya Veda Edin

Klasik CAPTCHA’ların kullanıcı deneyimini ne kadar kötü etkilediğini hepimiz biliriz. “Bisiklet seç”, “trafik ışığı bul”, onlarca kareyi tıkla… Hem sinir bozucu hem de erişilebilirlik açısından sorunlu. Cloudflare Turnstile ise bu probleme gerçek anlamda iyi bir çözüm sunuyor. Bot trafığini engellerken kullanıcıyı neredeyse hiç rahatsız etmiyor. Bu yazıda Turnstile’ı sıfırdan kurarak gerçek dünya senaryolarında nasıl kullanacağınızı ele alacağız.

Cloudflare Turnstile Nedir ve Neden Önemli?

Turnstile, Cloudflare’in 2022 yılında duyurduğu ve geleneksel CAPTCHA sistemlerine alternatif olarak geliştirdiği bir bot doğrulama servisidir. Google reCAPTCHA veya hCaptcha gibi alternatiflerin aksine, Turnstile kullanıcıdan genellikle herhangi bir görsel bulmaca çözmesini istemez. Arka planda tarayıcı sinyallerini, cihaz özelliklerini ve davranışsal analizi bir araya getirerek botları tespit eder.

Sysadmin perspektifinden baktığımızda Turnstile’ın öne çıkan birkaç kritik özelliği var:

  • Ücretsiz kullanım: Aylık 1 milyona kadar doğrulama isteği tamamen ücretsiz
  • Privacy-first yaklaşım: Google’ın veri toplama politikalarından rahatsız olanlar için ideal
  • CDN entegrasyonu: Cloudflare altyapısıyla zaten çalışıyorsanız latency neredeyse sıfır
  • Kolay API: Hem frontend hem backend entegrasyonu oldukça basit
  • Widget modları: Managed, Non-Interactive ve Invisible olarak üç farklı mod sunuyor

Managed mod en yaygın kullanılandır; Turnstile kullanıcının gerçek mi bot mu olduğunu kendi değerlendirip gerekirse küçük bir onay kutusu gösterir. Non-Interactive hiç müdahale gerektirmez. Invisible ise tamamen arka planda çalışır, kullanıcı hiçbir şey görmez.

Turnstile Site Anahtarı Oluşturma

İlk adım olarak Cloudflare Dashboard’dan site anahtarı almanız gerekiyor. Cloudflare hesabınıza giriş yapın, sol menüden Turnstile seçeneğine tıklayın. “Add site” butonuna basarak yeni bir widget oluşturun.

Bu aşamada şu bilgileri girmeniz gerekiyor:

  • Site name: İnsan tarafından okunabilir isim, sadece sizin için
  • Domain: Widget’ın çalışacağı domain ya da subdomain listesi
  • Widget mode: Managed, Non-Interactive veya Invisible seçimi

Kaydettikten sonra iki anahtar alacaksınız:

  • Site Key: Frontend kodunda herkese açık olarak kullanılır
  • Secret Key: Backend doğrulamasında kullanılır, asla public’e çıkmamalı

Bu anahtarları güvenli bir yere not edin. Secret key’i bir daha göremezsiniz, sıfırlamanız gerekir.

Temel Frontend Entegrasyonu

En basit haliyle Turnstile’ı bir HTML formuna entegre edelim. Cloudflare’in JavaScript dosyasını sayfaya ekleyip widget placeholder’ını yerleştirmek yeterli.

<!DOCTYPE html>
<html lang="tr">
<head>
    <meta charset="UTF-8">
    <title>İletişim Formu</title>
    <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head>
<body>
    <form method="POST" action="/submit">
        <div>
            <label for="email">E-posta:</label>
            <input type="email" id="email" name="email" required>
        </div>
        <div>
            <label for="message">Mesaj:</label>
            <textarea id="message" name="message" required></textarea>
        </div>

        <!-- Turnstile widget buraya ekleniyor -->
        <div class="cf-turnstile"
             data-sitekey="0x4AAAAAAAxxxxxxxxxxxxxx"
             data-callback="onTurnstileSuccess"
             data-error-callback="onTurnstileError"
             data-theme="auto">
        </div>

        <button type="submit">Gönder</button>
    </form>

    <script>
        function onTurnstileSuccess(token) {
            console.log('Turnstile doğrulandı, token:', token.substring(0, 20) + '...');
        }

        function onTurnstileError() {
            alert('Bot doğrulama başarısız oldu. Lütfen sayfayı yenileyin.');
        }
    </script>
</body>
</html>

Widget başarılı olduğunda cf-turnstile-response adında gizli bir input alanı forma otomatik eklenir. Bu token backend’e POST ile gönderilir ve orada doğrulanması gerekir.

JavaScript ile Programatik Kontrol

Formun submit olmasını Turnstile onaylanana kadar engellemek istiyorsanız biraz daha kontrol gerektiren bir yaklaşım kullanabilirsiniz.

// Turnstile yüklenince çağrılacak callback
window.onloadTurnstileCallback = function () {
    turnstile.render('#turnstile-container', {
        sitekey: '0x4AAAAAAAxxxxxxxxxxxxxx',
        theme: 'auto',
        language: 'tr',
        callback: function(token) {
            document.getElementById('submit-btn').disabled = false;
            document.getElementById('cf-token').value = token;
        },
        'expired-callback': function() {
            document.getElementById('submit-btn').disabled = true;
            console.warn('Turnstile token süresi doldu, yenileniyor...');
            turnstile.reset('#turnstile-container');
        },
        'error-callback': function(errorCode) {
            console.error('Turnstile hata kodu:', errorCode);
            document.getElementById('submit-btn').disabled = true;
        }
    });
};

// Form gönderilmeden önce token kontrolü
document.getElementById('contact-form').addEventListener('submit', function(e) {
    const token = document.getElementById('cf-token').value;
    if (!token) {
        e.preventDefault();
        alert('Lütfen bot doğrulamasını tamamlayın.');
        return false;
    }
});

Bu yaklaşımda script tag’ını ?onload=onloadTurnstileCallback&render=explicit parametreleriyle yüklemeniz gerekir:

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback&render=explicit" defer></script>

Backend Doğrulama: PHP ile

Frontend’den gelen token tek başına yeterli değildir. Token’ı Cloudflare’in doğrulama API’sine göndererek geçerliliğini teyit etmeniz şart. Aksi halde biri token’ı bypass edip doğrudan form verisi gönderebilir.

<?php
function verifyTurnstileToken($token, $remoteIp = null) {
    $secretKey = getenv('TURNSTILE_SECRET_KEY');
    
    if (empty($token)) {
        return ['success' => false, 'error' => 'Token eksik'];
    }
    
    $data = [
        'secret'   => $secretKey,
        'response' => $token,
    ];
    
    // IP adresi göndermek isteğe bağlı ama önerilir
    if ($remoteIp) {
        $data['remoteip'] = $remoteIp;
    }
    
    $ch = curl_init('https://challenges.cloudflare.com/turnstile/v0/siteverify');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
    curl_setopt($ch, CURLOPT_TIMEOUT, 10);
    
    $response = curl_exec($ch);
    $httpCode  = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    if ($response === false || $httpCode !== 200) {
        return ['success' => false, 'error' => 'API isteği başarısız'];
    }
    
    $result = json_decode($response, true);
    return $result;
}

// Form işleme
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $token     = $_POST['cf-turnstile-response'] ?? '';
    $clientIp  = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $_SERVER['REMOTE_ADDR'];
    
    $verification = verifyTurnstileToken($token, $clientIp);
    
    if (!$verification['success']) {
        http_response_code(403);
        $errors = implode(', ', $verification['error-codes'] ?? ['unknown']);
        die("Bot doğrulama başarısız: " . htmlspecialchars($errors));
    }
    
    // Doğrulama başarılı, form işlemlerine devam et
    $email   = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
    $message = htmlspecialchars($_POST['message'] ?? '');
    
    // ... geri kalan form işlemleri
    echo "Form başarıyla gönderildi!";
}
?>

Dikkat edin: $_SERVER['HTTP_CF_CONNECTING_IP'] header’ı, Cloudflare proxy’si arkasındaki gerçek kullanıcı IP’sini verir. Eğer siteniz Cloudflare üzerinden geçiyorsa REMOTE_ADDR yerine bunu kullanmalısınız.

Backend Doğrulama: Node.js ile

Eğer stack’iniz Node.js ise doğrulama şu şekilde yapılabilir:

const express = require('express');
const axios   = require('axios');
const app     = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

async function verifyTurnstile(token, ip) {
    const secretKey = process.env.TURNSTILE_SECRET_KEY;
    
    try {
        const response = await axios.post(
            'https://challenges.cloudflare.com/turnstile/v0/siteverify',
            new URLSearchParams({
                secret:   secretKey,
                response: token,
                remoteip: ip || ''
            }),
            {
                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                timeout: 8000
            }
        );
        
        return response.data;
    } catch (error) {
        console.error('Turnstile API hatası:', error.message);
        return { success: false, 'error-codes': ['api-error'] };
    }
}

app.post('/api/contact', async (req, res) => {
    const { email, message } = req.body;
    const token = req.body['cf-turnstile-response'];
    
    // Cloudflare proxy arkasındaysa gerçek IP
    const clientIp = req.headers['cf-connecting-ip'] || req.ip;
    
    const verification = await verifyTurnstile(token, clientIp);
    
    if (!verification.success) {
        return res.status(403).json({
            error: 'Bot doğrulama başarısız',
            codes: verification['error-codes']
        });
    }
    
    // hostname kontrolü ekleyin (güvenlik için)
    if (verification.hostname !== 'yourdomain.com') {
        return res.status(403).json({ error: 'Geçersiz kaynak domain' });
    }
    
    // Form işlemleri...
    res.json({ success: true, message: 'Form alındı' });
});

app.listen(3000, () => console.log('Sunucu 3000 portunda çalışıyor'));

Nginx ile Rate Limiting Kombinasyonu

Turnstile tek başına yeterli bir güvenlik katmanı oluşturur ancak Nginx tarafında rate limiting ile birleştirdiğinizde çok daha sağlam bir yapı kurabilirsiniz. Özellikle yüksek trafikli giriş ve kayıt sayfaları için bu kombinasyon çok kritik.

# /etc/nginx/conf.d/rate-limits.conf

# Form endpoint'leri için özel zone
limit_req_zone $binary_remote_addr zone=form_submit:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=login_attempt:10m rate=3r/m;

# Cloudflare IP'leri için ayrı zone (daha yüksek limit)
geo $cloudflare_client {
    default         0;
    # Cloudflare IP aralıkları
    103.21.244.0/22  1;
    103.22.200.0/22  1;
    103.31.4.0/22    1;
    104.16.0.0/13    1;
    104.24.0.0/14    1;
}
# /etc/nginx/sites-available/myapp.conf

server {
    listen 443 ssl http2;
    server_name yourdomain.com;

    # Form submit endpoint
    location /api/contact {
        limit_req zone=form_submit burst=2 nodelay;
        limit_req_status 429;

        # 429 döndüğünde kullanıcıya açıklayıcı mesaj
        error_page 429 /too-many-requests.html;

        proxy_pass http://127.0.0.1:3000;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
    }

    # Login sayfası için daha sıkı limit
    location /auth/login {
        limit_req zone=login_attempt burst=1 nodelay;
        limit_req_status 429;

        proxy_pass http://127.0.0.1:3000;
    }
}

Bu yapıda bile birisi dakikada 5 kez form göndermeye çalışırsa Nginx seviyesinde bloklanır, Turnstile doğrulamasına bile ulaşamaz.

Çok Sayfalı Uygulama Senaryosu: Next.js

Modern web uygulamalarında Turnstile entegrasyonu biraz daha farklı ele alınmalı. Özellikle Next.js gibi framework’lerde hem SSR hem client-side rendering söz konusu olduğundan dikkatli olunması gerekiyor.

// components/TurnstileWidget.jsx
import { useEffect, useRef, useCallback } from 'react';

const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;

export default function TurnstileWidget({ onVerify, onExpire, theme = 'auto' }) {
    const containerRef = useRef(null);
    const widgetIdRef  = useRef(null);

    const renderWidget = useCallback(() => {
        if (!window.turnstile || !containerRef.current) return;
        
        // Önceki widget varsa temizle
        if (widgetIdRef.current !== null) {
            window.turnstile.remove(widgetIdRef.current);
        }
        
        widgetIdRef.current = window.turnstile.render(containerRef.current, {
            sitekey:           TURNSTILE_SITE_KEY,
            theme:             theme,
            language:          'tr',
            callback:          onVerify,
            'expired-callback': onExpire,
            'error-callback': (code) => {
                console.error('Turnstile hata:', code);
            }
        });
    }, [onVerify, onExpire, theme]);

    useEffect(() => {
        // Script zaten yüklüyse direkt render et
        if (window.turnstile) {
            renderWidget();
            return;
        }

        // Script'i dinamik olarak yükle
        const script      = document.createElement('script');
        script.src        = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
        script.async      = true;
        script.onload     = renderWidget;
        document.head.appendChild(script);

        return () => {
            if (widgetIdRef.current !== null && window.turnstile) {
                window.turnstile.remove(widgetIdRef.current);
            }
        };
    }, [renderWidget]);

    return <div ref={containerRef} />;
}
// pages/api/verify-form.js (Next.js API Route)
export default async function handler(req, res) {
    if (req.method !== 'POST') {
        return res.status(405).json({ error: 'Metod desteklenmiyor' });
    }

    const { token, ...formData } = req.body;
    const clientIp = req.headers['cf-connecting-ip'] || req.socket.remoteAddress;

    const verifyResponse = await fetch(
        'https://challenges.cloudflare.com/turnstile/v0/siteverify',
        {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                secret:   process.env.TURNSTILE_SECRET_KEY,
                response: token,
                remoteip: clientIp
            })
        }
    );

    const verifyData = await verifyResponse.json();

    if (!verifyData.success) {
        return res.status(403).json({
            error: 'Doğrulama başarısız',
            codes: verifyData['error-codes']
        });
    }

    // challenge_ts kontrolü: token 5 dakikadan eski olmamalı
    const tokenAge = Date.now() - new Date(verifyData.challenge_ts).getTime();
    if (tokenAge > 300000) {
        return res.status(403).json({ error: 'Token süresi dolmuş' });
    }

    // Form verilerini işle
    return res.status(200).json({ success: true });
}

Turnstile Hata Kodları ve Troubleshooting

Prodüksiyon ortamında zaman zaman beklenmedik hatalar alabilirsiniz. Cloudflare’in döndürdüğü hata kodlarını iyi bilmek debug sürecini ciddi ölçüde kısaltır.

Sık karşılaşılan hata kodları ve anlamları:

  • missing-input-secret: Secret key POST body’ye eklenmemiş
  • invalid-input-secret: Secret key yanlış ya da başka widget’a ait
  • missing-input-response: Frontend’den token gönderilmemiş
  • invalid-input-response: Token geçersiz, bozuk ya da replay saldırısı
  • invalid-widget-id: Site key geçersiz
  • invalid-parsed-secret: Secret key format hatası
  • bad-request: İstek yapısı hatalı
  • timeout-or-duplicate: Token kullanılmış ya da süresi dolmuş (her token yalnızca bir kez kullanılabilir)
# Doğrulama API'sini curl ile test etmek için:
curl -s -X POST 
  https://challenges.cloudflare.com/turnstile/v0/siteverify 
  -d "secret=YOUR_SECRET_KEY" 
  -d "response=TOKEN_FROM_FRONTEND" 
  | python3 -m json.tool

Başarılı bir yanıt şöyle görünür:

# Başarılı doğrulama yanıtı örneği
{
    "success": true,
    "challenge_ts": "2024-01-15T10:30:00.000Z",
    "hostname": "yourdomain.com",
    "error-codes": [],
    "action": "",
    "cdata": ""
}

En sık yapılan hata, token’ı iki kez doğrulamaya çalışmaktır. Turnstile token’ları tek kullanımlıktır. Eğer mikroservis mimarisi kullanıyorsanız ve aynı token birden fazla servise iletiliyorsa bu problemi yaşarsınız. Çözüm olarak ilk doğrulama sonucunu Redis’te kısa süreliğine önbelleğe alabilirsiniz.

# Redis ile token sonucu önbelleğe alma örneği (Bash/Redis CLI)
# Token başarıyla doğrulandıktan sonra işaretleme
redis-cli SET "turnstile:used:${TOKEN_HASH}" "1" EX 600

# Daha önce kullanılıp kullanılmadığını kontrol etme
RESULT=$(redis-cli GET "turnstile:used:${TOKEN_HASH}")
if [ "$RESULT" = "1" ]; then
    echo "Bu token zaten kullanılmış, reddediliyor"
    exit 1
fi

Cloudflare Turnstile Analytics

Turnstile Dashboard üzerinden widget performansını takip edebilirsiniz. Burada dikkat etmeniz gereken metrikler:

  • Solved: Başarıyla tamamlanan doğrulama sayısı
  • Failed: Başarısız olan doğrulama sayısı (yüksekse kötü bot trafiği var demek)
  • Expired: Süresi dolan token sayısı (kullanıcılar formu çok uzun süre açık bırakıyorsa yükselir)
  • Abandon rate: Widget gösterilmesine rağmen tamamlanmayan oran (çok yüksekse UX problemi var olabilir)

Genellikle sağlıklı bir uygulamada fail oranı toplam isteklerin yüzde 5’ini geçmemeli. Bunun üzerindeyse ya sistematik bir saldırı altındasınızdır ya da widget konfigürasyonunda bir sorun vardır.

Gizlilik ve GDPR Uyumluluğu

Cloudflare Turnstile, reCAPTCHA’ya kıyasla çok daha az veri toplar. Google reCAPTCHA’nın kullanıcı davranışlarını reklam profilleme amacıyla kullandığı biliniyor ve bu durum GDPR açısından ciddi soru işaretleri doğuruyor. Turnstile ise sadece bot tespiti için gerekli minimum veriyi işliyor.

Yine de Avrupa’daki kullanıcılara hizmet veriyorsanız privacy policy’nizde Cloudflare Turnstile kullanımından bahsetmeniz ve veri işleme konusunda net bilgi vermeniz iyi bir uygulama olacaktır. Cloudflare’in DPA (Data Processing Agreement) belgesi mevcut ve GDPR uyumlu olduklarını beyan ediyorlar.

Cookie banner’ı olan sitelerde Turnstile için ayrı bir izin almanıza gerek yok; ancak hukuki danışmanlık almadan kesin karar vermemenizi öneririm.

Sonuç

Cloudflare Turnstile, klasik CAPTCHA çözümlerine kıyasla hem kullanıcı deneyimi hem de güvenlik açısından belirgin avantajlar sunuyor. Kurulumu ciddi anlamda basit, ücretsiz katmanı çoğu proje için yeterli ve mevcut Cloudflare altyapısıyla kusursuz entegre oluyor.

Pratik önerilerimi şöyle özetleyeyim: Secret key’i asla kaynak koduna gömmeden environment variable olarak saklayın. Backend doğrulamasını mutlaka yapın, sadece frontend token’ına güvenmek güvenlik açığı yaratır. Token’ların tek kullanımlık olduğunu unutmayın ve mikroservis mimarisinde buna göre tasarım yapın. Nginx rate limiting ile kombine ederek çok katmanlı bir savunma oluşturun. Son olarak hostname alanını doğrulayın; bu sayede başka bir sitede oluşturulmuş token’ların sizin API’nize gönderilmesinin önüne geçersiniz.

Mevcut reCAPTCHA veya hCaptcha entegrasyonunuz varsa geçiş yapmak oldukça kolay. Genellikle birkaç saatlik iş yüküyle tamamlanabilir ve kullanıcılarınız farkı hemen hisseder; artık trafik ışığı aramak yok.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir