# 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.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/spam-protection/cloudflare-turnstile

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.

Diagrama: A vida de um token do Turnstile, do widget ao veredito

1. O widget do Turnstile roda no navegador com sua site key pública. A maioria dos visitantes nunca o vê fazer nada.
2. Ele emite um token e o coloca num campo oculto cf-turnstile-response dentro do formulário.
3. O formulário é enviado, e o token vai junto com os outros campos.
4. Seu servidor envia a chave secreta, o token e o IP do visitante ao endpoint siteverify da Cloudflare.
5. Ele confere se a resposta diz success e se o hostname e a action são os esperados.
6. Se tudo bater, o envio é aceito. Se algo estiver errado, é recusado.

Um token vale por 300 segundos e só pode ser validado uma vez. Uma segunda verificação do mesmo token falha com timeout-or-duplicate.

## 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.

`contact.html`:

```html
<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.com` tanto em `script-src` quanto em `frame-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.

`src/index.ts`:

```ts
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_key` permite repetir uma verificação com segurança após um erro de rede.
- Confira `hostname` e `action`, não só `success`. Um token emitido na página de outra pessoa com a sua site key também diz `success`.
- Guarde os dois segredos com `wrangler secret put`, nunca em `wrangler.toml`.
- O Worker responde com um redirecionamento `303`, então o formulário simples não precisa de JavaScript. Aponte o `action` do formulário para a rota do Worker.
- Ele repassa o cabeçalho `Origin` da página, porque um fetch do lado do servidor não envia nenhum e o Ikiform o lê para reconhecer testes gratuitos em `localhost`.

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:

`contact.html`:

```html
<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>
```

> As versões do lado do servidor enviam com uma chave secreta, então o Ikiform pula **Allowed domains** e a verificação humana dele. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o [limite de taxa](https://ikiform.com/pt-br/docs/rate-limits.md) Relaxed ou Custom para um formulário enviado por um servidor e mantenha `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.

`contact.html`:

```html
<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](https://ikiform.com/pt-br/docs/spam.md#human-check) 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

1. Envie o formulário normalmente. O envio é aceito e o widget não mostrou nada, ou só uma caixa de seleção.
2. Remova o elemento `cf-turnstile` e envie. O envio é recusado: o Worker redireciona com `?error=check`, ou o Ikiform responde `403 captcha_failed`.
3. Envie, volte e envie a mesma página de novo. O token reutilizado falha, o que mostra que o servidor realmente o valida.
4. Abra **Submissions** no painel. O novo envio está lá com todos os campos que você mandou.
5. 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`, chame `turnstile.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-expired` do widget decide se ele busca um novo sozinho ou espera um clique.
- **www e o domínio raiz.** `www.example.com` e `example.com` sã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=explicit` e `turnstile.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

### Posso usar o Cloudflare Turnstile sem servidor?

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.

### O Cloudflare Turnstile é gratuito?

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.

### Por que o siteverify diz timeout-or-duplicate?

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.

### Preciso de uma conta na Cloudflare para usar o Turnstile com o Ikiform?

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.

## Mais guias

- [Acabe com o spam no formulário de contato](https://ikiform.com/pt-br/guides/spam-protection.md): Acabe com o spam no formulário de contato com camadas que não custam nada a pessoas reais: honeypot, tempo, pontuação de conteúdo, limites de taxa e verificação humana só quando precisar.
- [Alternativas ao reCAPTCHA](https://ikiform.com/pt-br/guides/spam-protection/recaptcha-alternatives.md): Compare alternativas ao reCAPTCHA para formulários, do Turnstile e hCaptcha a prova de trabalho e nenhum captcha, e migre com uma mudança pequena e segura.
- [Sites estáticos](https://ikiform.com/pt-br/guides/spam-protection/static-site.md): Acabe com o spam num formulário de site estático mesmo sem guardar segredos nem contar envios, com um partial de honeypot, um script seguro e verificações no servidor.
- [Sem captcha](https://ikiform.com/pt-br/guides/spam-protection/without-captcha.md): Barre a maior parte do spam em formulários sem captcha nenhum: honeypot, horário de renderização assinado, pontuação de conteúdo e limite de taxa, somados em vez de usados sozinhos.
- [Campo honeypot](https://ikiform.com/pt-br/guides/spam-protection/honeypot.md): Adicione a qualquer formulário um honeypot que pega bots sem atrapalhar leitores de tela, teclados ou preenchimento automático, com a verificação no servidor para cada stack.
- [Limite de taxa](https://ikiform.com/pt-br/guides/spam-protection/rate-limiting.md): Limite a taxa de envios por visitante com um armazenamento compartilhado, responda 429 com Retry-After e evite bloquear escritórios que compartilham um IP.
