O Cloudflare Turnstile é uma alternativa a CAPTCHAs tradicionais: o visitante passa por uma verificação leve (muitas vezes imperceptível), e o servidor confirma essa interação com a API da Cloudflare antes de aceitar ações sensíveis, login, cadastro ou recuperação de senha.
Este artigo descreve um fluxo completo em Laravel, do cadastro das chaves ao widget no Blade e à validação server-side, no estilo de uma aplicação que separa configuração, serviço HTTP, regra de validação customizada, Form Requests e componente Blade reutilizável.
Por que isso importa?
Bots abusam login, cadastro e “esqueci minha senha”. O Turnstile é o CAPTCHA/alternativa da Cloudflare: widget leve, foco em UX e verificação no servidor. Integrar com Laravel de forma limpa significa: chaves em config, uma chamada HTTP à API siteverify, regra de validação reutilizável e um pedaço de front que envia o token no mesmo POST do formulário.
O que você vai precisar?
- Conta na Cloudflare e acesso ao Turnstile.
- Um sistema/site com Laravel (no exemplo, padrão Form Request + Blade).
- Duas chaves: site key (pública, no HTML) e secret key (só no servidor — nunca no front).
1. Criar o site no painel Turnstile
No dashboard da Cloudflare, crie um widget Turnstile para o domínio da aplicação. Anote a site key e a secret key. Domínios de desenvolvimento podem ser adicionados nas configurações do widget para testar em homologação sem expor produção.
2. Variáveis de ambiente e configuração
No .env (valores ilustrativos — use as suas chaves reais):
CLOUDFLARE_TURNSTILE_SITE_KEY=0x4AAAAAAAxxxxxxxxxxxxxxxxxxxxxxxx
CLOUDFLARE_TURNSTILE_SECRET_KEY=0x4AAAAAAAyyyyyyyyyyyyyyyyyyyyyyyy
No config/services.php:
'cloudflare' => [
'turnstile' => [
'site_key' => env('CLOUDFLARE_TURNSTILE_SITE_KEY'),
'secret_key' => env('CLOUDFLARE_TURNSTILE_SECRET_KEY'),
],
],
Depois: php artisan config:clear ao mudar .env em desenvolvimento.
Leia também: Autenticação com Laravel Sanctum
3. Serviço que conversa com a Cloudflare
A API oficial espera POST form-urlencoded para https://challenges.cloudflare.com/turnstile/v0/siteverify com secret, response (o token) e, opcionalmente, remoteip.
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CloudflareTurnstileService
{
protected string $secretKey;
protected string $verifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
public function __construct()
{
$this->secretKey = config('services.cloudflare.turnstile.secret_key');
}
public function verify(string $token, ?string $ip = null): bool
{
if (empty($token) || empty($this->secretKey)) {
return false;
}
try {
$response = Http::asForm()->post($this->verifyUrl, [
'secret' => $this->secretKey,
'response' => $token,
'remoteip' => $ip ?? request()->ip(),
]);
$result = $response->json();
if (! isset($result['success']) || $result['success'] !== true) {
Log::warning('Cloudflare Turnstile verification failed', [
'errors' => $result['error-codes'] ?? [],
'ip' => $ip ?? request()->ip(),
]);
return false;
}
return true;
} catch (\Exception $e) {
Log::error('Cloudflare Turnstile verification error', [
'message' => $e->getMessage(),
'ip' => $ip ?? request()->ip(),
]);
return false;
}
}
}
Ideia central: o token é descartável e válido por pouco tempo; a fonte da verdade é a resposta JSON da Cloudflare com success: true.
4. Regra de validação reutilizável
Assim você não repete lógica em cada controller e mantém a mensagem padronizada.
<?php
namespace App\Rules;
use App\Services\CloudflareTurnstileService;
use Illuminate\Contracts\Validation\ValidationRule;
class CloudflareTurnstile implements ValidationRule
{
public function validate(string $attribute, mixed $value, \Closure $fail): void
{
if (app()->environment('local')) {
return;
}
$service = app(CloudflareTurnstileService::class);
if (! $service->verify($value, request()->ip())) {
$fail('A verificação do Cloudflare Turnstile falhou. Por favor, tente novamente.');
}
}
}
Por que pular local: em desenvolvimento você evita dependência de chaves e do widget a cada refresh. Em CI, avalie testing da mesma forma ou use variáveis de ambiente de teste no Turnstile.
5. Form Request
O restante do request (email, senha, etc.) é o seu domínio. O padrão é condicionar regra e campo ao ambiente, alinhado ao componente Blade:
public function rules(): array
{
$rules = [
// ... suas regras (email, password, etc.)
];
if (! app()->environment('local')) {
$rules['cf-turnstile-response'] = ['required', new \App\Rules\CloudflareTurnstile()];
}
return $rules;
}
O nome do campo cf-turnstile-response é o que a documentação da Cloudflare usa no POST; manter esse nome evita surpresas com exemplos oficiais e snippets.
Leia também: Multi-tenancy no Laravel
6. Componente Blade: widget + token oculto
Renderizar o widget só quando há site_key e não estiver em local evita erros de JS e formulários quebrados em dev.
resources/views/components/common/cloudflare-turnstile.blade.php:
@if(config('services.cloudflare.turnstile.site_key') && !app()->environment('local'))
<div class="cf-turnstile"
data-sitekey="{{ config('services.cloudflare.turnstile.site_key') }}"
data-callback="onTurnstileSuccess"
data-error-callback="onTurnstileError"
data-expired-callback="onTurnstileExpired"></div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script>
function onTurnstileSuccess(token) {
const input = document.getElementById('cf-turnstile-response');
if (input) {
input.value = token;
}
}
function onTurnstileError() {
const input = document.getElementById('cf-turnstile-response');
if (input) {
input.value = '';
}
}
function onTurnstileExpired() {
const input = document.getElementById('cf-turnstile-response');
if (input) {
input.value = '';
}
}
</script>
<input type="hidden" id="cf-turnstile-response" name="cf-turnstile-response" value="">
@endif
Callbacks: em sucesso, o token vai para o hidden; em erro ou expiração, limpa o valor para o required do Laravel falhar de propósito e o usuário refazer o desafio.
7. Usar no formulário
Coloque o componente dentro do <form>, idealmente antes do botão de envio, centralize visualmente se quiser, e exiba erros de validação:
<div class="flex justify-center">
<x-common.cloudflare-turnstile />
</div>
@error('cf-turnstile-response')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
Repita o mesmo bloco em login, registro, recuperação e reset de senha — sempre que quiser proteger o endpoint com Turnstile.
8. Encaixando o “meio” ao “fim”: checklist mental
- Usuário carrega a página → widget aparece (exceto local).
- Cloudflare executa o desafio →
onTurnstileSuccesspreenche o hidden. - Submit → Laravel valida
cf-turnstile-response→ Rule chamasiteverify. - Se
successfortrue, o restante da validação segue; senão, mensagem única e log comerror-codespara debug.
Segurança: a secret nunca vai para o Blade; só o site_key é público. Rate limiting no login continua valendo, Turnstile não substitui throttle, complementa.
9. Conclusão
Implementar o Cloudflare Turnstile em um app Laravel não é só “colocar um script na página”: é combinar três camadas que precisam estar alinhadas, configuração (env + config), verificação server-side contra o endpoint oficial siteverify, e experiência no formulário (widget + campo oculto + mensagens de erro). Quando isso está consistente, você reduz bots e abuso em login, cadastro e recuperação de senha sem depender só de rate limiting ou de CAPTCHAs pesados para o usuário.
O desenho que vimos, serviço HTTP dedicado, regra de validação customizada, Form Requests que só exigem o token fora do ambiente local, e componente Blade que só renderiza quando há site_key e não é local, mantém responsabilidades claras: o controller continua fino, a política “desligar no dev” fica repetível e previsível, e o segredo permanece só no servidor.
Do ponto de vista de segurança operacional, vale fixar três ideias: a site key pode aparecer no HTML (é pública); a secret key só em variável de ambiente e referenciada via config(); em logs, prefira códigos de erro da API e contexto mínimo (por exemplo IP), sem registrar o token inteiro. Se houver proxy reverso, garantir IP correto para remoteip evita falsos negativos na verificação.
Para fechar o ciclo em produção, confira: variáveis preenchidas no ambiente de deploy, domínio do site autorizado no painel do Turnstile, widget visível nos fluxos que você protegeu, e um teste manual (ou teste automatizado com HTTP fake) simulando sucesso e falha da API. Em desenvolvimento local, pular widget e validação acelera o time, desde que todos saibam que a proteção real só vale onde o ambiente não é local (ou a regra que vocês adotarem).
Em suma: Turnstile bem integrado ao Laravel é config + serviço + regra + requests + Blade, todos falando a mesma língua sobre ambiente e campo cf-turnstile-response. Com isso, quem lê o post ou o código consegue não só copiar trechos, mas entender por que cada parte existe e como manter isso seguro e sustentável no tempo.