Acabe com o spam no formulário de contato7 min de leitura
Como adicionar o Cloudflare Turnstile a um formulário HTML
Adicione o Cloudflare Turnstile a um formulário HTML simples: o widget, o siteverify no servidor, chaves de teste e um jeito de rodá-lo sem chaves nem servidor próprio.
Adicione o script do Turnstile e um elemento cf-turnstile dentro do formulário; o widget coloca um token num campo oculto cf-turnstile-response. Depois envie esse token e sua chave secreta ao endpoint siteverify da Cloudflare a partir de um servidor, e aceite o envio só se a resposta for success. Sem servidor, deixe seu backend de formulários validá-lo, ou use a verificação gerenciada do Ikiform, que não precisa de chave nenhuma.
O que muda com o Turnstile
O Turnstile tem duas metades, e um formulário HTML simples só tem espaço para uma. O widget roda no navegador com sua site key pública e gera um token. A verificação roda num servidor com sua secret key, que nunca pode chegar à página. Um formulário que mostra o widget mas nunca valida o token é enfeite: um bot envia direto para o seu endpoint e pula o widget por completo.
Então a pergunta real para um formulário HTML é onde fica o segredo. Esta página cobre três respostas: seu próprio servidor pequeno, um backend de formulários que guarda suas chaves e uma verificação gerenciada em que só o backend tem chaves.
- O widget do Turnstile roda no navegador com sua site key pública. A maioria dos visitantes nunca o vê fazer nada.
- Ele emite um token e o coloca num campo oculto cf-turnstile-response dentro do formulário.
- O formulário é enviado, e o token vai junto com os outros campos.
- Seu servidor envia a chave secreta, o token e o IP do visitante ao endpoint siteverify da Cloudflare.
- Ele confere se a resposta diz success e se o hostname e a action são os esperados.
- Se tudo bater, o envio é aceito. Se algo estiver errado, é recusado.
Adicione o widget
Crie um widget no painel da Cloudflare em Turnstile, liste os hostnames onde seu formulário roda e copie a site key. Depois adicione o script e um elemento cf-turnstile dentro do formulário. O elemento precisa estar dentro do <form>, senão o campo oculto do token fica fora dele e nunca é enviado.
<form action="https://example.com/api/contact" method="POST">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
<button>Send message</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>data-action="contact"rotula o widget. O siteverify o devolve, então seu servidor pode recusar um token emitido para outro formulário.- O script precisa ser carregado da URL da própria Cloudflare. A Cloudflare diz que uma cópia via proxy ou hospedada por você quebra o widget.
- Se seu site envia uma Content Security Policy, permita
https://challenges.cloudflare.comtanto emscript-srcquanto emframe-src. - Sem custo, a Cloudflare permite até 20 widgets, cada um em até 10 hostnames, então liste também os hosts de staging.
Valide o token no seu próprio servidor
Qualquer servidor pode validar um token: um POST ao siteverify com o segredo e o token. Aqui está como um Cloudflare Worker para o qual um formulário estático envia. Ele confere o token, o hostname e a action, descarta o token e repassa os campos ao Ikiform com uma chave secreta. Troque o repasse por qualquer outra coisa que guarde ou envie a mensagem por e-mail.
interface Env {
TURNSTILE_SECRET_KEY: string;
IKIFORM_SECRET_KEY: string;
}
interface Verdict {
success: boolean;
hostname?: string;
action?: string;
"error-codes"?: string[];
}
const SITE = "https://example.com";
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
const form = await request.formData();
const check = await fetch(SITEVERIFY, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: env.TURNSTILE_SECRET_KEY,
response: String(form.get("cf-turnstile-response") ?? ""),
remoteip: request.headers.get("CF-Connecting-IP"),
idempotency_key: crypto.randomUUID(),
}),
});
const verdict = (await check.json()) as Verdict;
const expected = verdict.hostname === new URL(SITE).hostname && verdict.action === "contact";
if (!verdict.success || !expected) {
console.warn("turnstile", verdict["error-codes"] ?? [], verdict.hostname, verdict.action);
return Response.redirect(`${SITE}/contact?error=check`, 303);
}
form.delete("cf-turnstile-response");
const sent = await fetch(ENDPOINT, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${env.IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("Origin") ?? SITE,
},
body: form,
});
return Response.redirect(sent.ok ? `${SITE}/thanks` : `${SITE}/contact?error=send`, 303);
},
};- O siteverify aceita corpos JSON ou form-encoded e sempre responde em JSON.
remoteipé opcional;idempotency_keypermite repetir uma verificação com segurança após um erro de rede. - Confira
hostnameeaction, não sósuccess. Um token emitido na página de outra pessoa com a sua site key também dizsuccess. - Guarde os dois segredos com
wrangler secret put, nunca emwrangler.toml. - O Worker responde com um redirecionamento
303, então o formulário simples não precisa de JavaScript. Aponte oactiondo formulário para a rota do Worker. - Ele repassa o cabeçalho
Originda página, porque um fetch do lado do servidor não envia nenhum e o Ikiform o lê para reconhecer testes gratuitos emlocalhost.
Para mostrar o erro na página do formulário após uma verificação que falhou, leia a query string que o Worker define:
<p id="form-error" role="alert" hidden></p>
<script>
const reason = new URLSearchParams(location.search).get("error");
const banner = document.getElementById("form-error");
if (reason && banner) {
banner.textContent =
reason === "check" ? "The human check didn't pass. Try again." : "Your message didn't send. Try again.";
banner.hidden = false;
}
</script>IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.Deixe o Ikiform validar
Se você não quer rodar servidor nenhum, o formulário pode enviar direto ao Ikiform e o Ikiform faz a chamada ao siteverify. A opção Spam check de cada formulário decide como.
| Caminho | Chaves que você gerencia | Widget na sua página | Servidor próprio |
|---|---|---|---|
| Seu próprio servidor ou Worker, acima | Site key e chave secreta | Sim, o seu | Sim |
| Ikiform com Your own Turnstile keys | Site key e chave secreta, coladas no Ikiform | Sim, o seu | Não |
| Ikiform com Ikiform | Nenhum | Não. Uma página de verificação em ikiform.com, ou um pequeno frame | Não |
Com Your own Turnstile keys, cole a site key e a chave secreta nas configurações do formulário. O segredo é criptografado em repouso. Mantenha o mesmo markup do widget, mas aponte o formulário para o seu endpoint do Ikiform. O Ikiform valida cf-turnstile-response e confere o hostname do token com os Allowed domains do formulário. Um envio sem token válido recebe 403 captcha_failed, mesmo com uma chave de API secreta.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
<button>Send message</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>Com Ikiform, você não adiciona nada à página. Um envio HTML simples sem token recebe uma breve página Checking you're human (verificando se você é humano) em ikiform.com que roda o Turnstile e repassa os campos. Um envio com fetch recebe 403 challenge_required em vez disso; chame challenge() de @ikiform/sdk para obter um token antes. Nenhuma conta da Cloudflare entra na história. A documentação de spam mostra os dois fluxos.
Se a Cloudflare não responder, o Ikiform não descarta o envio: ele o guarda como spam com o motivo captcha_unavailable, para você poder restaurá-lo pelo painel. Seu próprio servidor deve tomar essa mesma decisão de forma deliberada, ou falhando de forma fechada como o Worker acima, ou mantendo o envio para revisão.
Chaves de teste
A Cloudflare publica chaves fictícias que funcionam em qualquer hostname, inclusive localhost. Use-as enquanto desenvolve e depois troque pelas suas chaves reais.
| Chave | Valor | Comportamento |
|---|---|---|
| Site key | 1x00000000000000000000AA | Sempre passa, widget visível |
| Chave secreta | 1x0000000000000000000000000000000AA | O siteverify sempre responde sucesso |
| Chave secreta | 2x0000000000000000000000000000000AA | O siteverify sempre responde falha |
Tokens de teste não trazem seu hostname real, então a verificação de hostname do Worker os recusa. Afrouxe essa verificação só no seu ambiente de desenvolvimento, nunca em produção.
Confira se funciona
- Envie o formulário normalmente. O envio é aceito e o widget não mostrou nada, ou só uma caixa de seleção.
- Remova o elemento
cf-turnstilee envie. O envio é recusado: o Worker redireciona com?error=check, ou o Ikiform responde403 captcha_failed. - Envie, volte e envie a mesma página de novo. O token reutilizado falha, o que mostra que o servidor realmente o valida.
- Abra Submissions no painel. O novo envio está lá com todos os campos que você mandou.
- Nada chegou? Mude o filtro de status do painel para Spam. O spam recebe a mesma resposta de sucesso que um envio real.
Pegadinhas
- Um token, uma tentativa. Após um envio com falha tratado com
fetch, chameturnstile.reset()para obter um token novo. Um recarregamento completo da página já traz um automaticamente. - Quem digita devagar. Tokens expiram após 300 segundos. A opção
data-refresh-expireddo widget decide se ele busca um novo sozinho ou espera um clique. - www e o domínio raiz.
www.example.comeexample.comsão hostnames diferentes para o siteverify. Compare com aquele em que seu formulário realmente roda, ou permita os dois. - Navegação de página única. O script implícito renderiza os widgets uma vez, quando carrega. Numa troca de rota no cliente, renderize explicitamente com
?render=expliciteturnstile.render(). - Envios de servidor com as chaves do próprio Ikiform. Se seu servidor repassa ao Ikiform com uma chave secreta, o Ikiform pula a verificação gerenciada dele. Valide o token você mesmo antes, como o Worker faz.
Perguntas
O widget roda em qualquer lugar, mas o token precisa ser validado com sua chave secreta num servidor. Sem um, envie o formulário a um backend de formulários que o valide para você, ou use a verificação gerenciada do Ikiform, que dispensa chaves.
A Cloudflare o oferece sem custo para até 20 widgets com até 10 hostnames cada, e a página de planos dela lista desafios ilimitados. Confira a página de planos atual da Cloudflare antes de contar com isso.
O token tem mais de 300 segundos ou já foi validado uma vez. Renderize um widget novo, ou reinicie-o, após cada tentativa de envio.
Não. Defina o Spam check do formulário como Ikiform e o Ikiform roda o Turnstile em ikiform.com. Você só precisa de uma conta para usar suas próprias chaves e seu próprio widget.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.