# Como acabar com o spam em formulários sem captcha

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

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

Combine quatro verificações invisíveis e some o que elas encontram: um honeypot, um timestamp assinado que mostra por quanto tempo o formulário ficou aberto, uma pontuação para links e frases de spam e um limite de taxa por visitante. Trate um envio como spam só quando o total passar de um limite, para que nenhum sinal leve isolado bloqueie uma pessoa real. Guarde o que você barrar e adicione um captcha depois só se a pasta de spam mostrar que bots ainda passam.

## O que muda sem captcha

Um captcha dá um sim ou não forte. Sem ele, você tem vários sinais fracos, e cada um sozinho é fácil de driblar ou de disparar por acidente. Um envio rápido pode ser um bot, ou alguém que colou uma mensagem pronta. Um link pode ser spam, ou um portfólio. O segredo é pontuar: dê um peso a cada sinal, some tudo e só chame de spam acima de um limite. O único sinal forte, um honeypot preenchido, recebe um peso grande o bastante para cruzar a linha sozinho.

Diagrama: Como sinais invisíveis somam um veredito para três envios diferentes

1. Um bot de script preenche o honeypot, o que soma 5, e envia em menos de um segundo, o que soma 3. O total de 8 é spam.
2. Uma oferta de SEO colada tem um link no campo de nome, 3, e uma frase de spam conhecida, 2. O total de 5 atinge o limite de 5.
3. Uma mensagem real com três links soma 2 e nada mais, então é recebida.

Os pesos são os do Ikiform, com limite de 5. Dois sinais leves juntos o cruzam; um só, nunca.

## Um horário de renderização assinado

Pessoas precisam de vários segundos para preencher um formulário. Uma armadilha de tempo mede esse intervalo. A versão fraca coloca `Date.now()` num campo oculto, que qualquer bot pode reescrever. A versão forte assina o horário de renderização no servidor com um segredo, então um bot não consegue alegar que a página foi aberta antes do que realmente foi. Este arquivo roda em qualquer lugar com Web Crypto: Node 20 ou mais recente, Deno, Bun, Workers e todo runtime serverless.

`lib/render-token.ts`:

```ts
const encoder = new TextEncoder();

async function key(secret: string) {
  return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
    "sign",
    "verify",
  ]);
}

function toHex(bytes: ArrayBuffer) {
  return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}

function fromHex(hex: string) {
  return new Uint8Array((hex.match(/../g) ?? []).map((pair) => Number.parseInt(pair, 16)));
}

export async function signRender(secret: string, now = Date.now()) {
  const signature = await crypto.subtle.sign("HMAC", await key(secret), encoder.encode(`render.${now}`));
  return `${now}.${toHex(signature)}`;
}

export type RenderCheck = "ok" | "too_fast" | "invalid";

export async function checkRender(secret: string, token: string, minSeconds = 3, now = Date.now()): Promise<RenderCheck> {
  const [time = "", signature = ""] = token.split(".");
  if (!/^\d{13}$/.test(time) || !/^[0-9a-f]{64}$/.test(signature)) return "invalid";
  const valid = await crypto.subtle.verify("HMAC", await key(secret), fromHex(signature), encoder.encode(`render.${time}`));
  const age = now - Number(time);
  if (!valid || age < 0 || age > 86_400_000) return "invalid";
  return age < minSeconds * 1000 ? "too_fast" : "ok";
}
```

Assine quando a página for renderizada, então a página precisa ser renderizada a cada requisição, não ficar em cache como HTML estático. No Astro:

`src/pages/contact.astro`:

```astro
---
import { signRender } from "../lib/render-token";

export const prerender = false;

const rendered = await signRender(import.meta.env.RENDER_SECRET);
---

<form method="POST" action="/api/contact">
  <input type="hidden" name="rendered" value={rendered} />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <label for="name">Name</label>
  <input id="name" name="name" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>
  <button>Send</button>
</form>
```

- Três segundos é um mínimo seguro para um formulário de contato. Alguém colando uma mensagem pronta ainda pode levar dois.
- Rejeite tokens com mais de um dia. Senão um bot carrega a página uma vez e reutiliza o mesmo token por semanas.
- Trate um token ausente ou forjado como um sinal, não como prova. Uma página em cache ou uma aba antiga podem gerar um.

## Pontue o envio

Junte os sinais numa função só. As verificações de links e frases pegam o que o honeypot e o tempo não pegam: mensagens digitadas ou coladas por pessoas. Mantenha a lista de frases curta e específica; palavras amplas como `free` marcam clientes reais.

`lib/score.ts`:

```ts
const WEIGHTS = { honeypot: 5, too_fast: 3, invalid_render: 3, link_in_name: 3, many_links: 2, phrase: 2 };
const THRESHOLD = 5;
const LINK = /https?:\/\/|www\./gi;
const PHRASES = /\b(seo services|guest post|backlinks|first page of google|crypto investment)\b/i;

type Reason = keyof typeof WEIGHTS;

export function scoreContact(input: {
  name: string;
  message: string;
  trap: string;
  render: "ok" | "too_fast" | "invalid";
}) {
  const reasons: Reason[] = [];
  if (input.trap.trim()) reasons.push("honeypot");
  if (input.render === "too_fast") reasons.push("too_fast");
  if (input.render === "invalid") reasons.push("invalid_render");
  if (input.name.match(LINK)) reasons.push("link_in_name");
  if ((`${input.name} ${input.message}`.match(LINK)?.length ?? 0) >= 3) reasons.push("many_links");
  if (PHRASES.test(`${input.name} ${input.message}`)) reasons.push("phrase");
  const score = reasons.reduce((total, reason) => total + WEIGHTS[reason], 0);
  return { score, reasons, spam: score >= THRESHOLD };
}
```

`src/pages/api/contact.ts`:

```ts
import type { APIRoute } from "astro";
import { checkRender } from "../../lib/render-token";
import { scoreContact } from "../../lib/score";

export const POST: APIRoute = async ({ request, redirect }) => {
  const form = await request.formData();
  const render = await checkRender(import.meta.env.RENDER_SECRET, String(form.get("rendered") ?? ""));
  const verdict = scoreContact({
    name: String(form.get("name") ?? ""),
    message: String(form.get("message") ?? ""),
    trap: String(form.get("_gotcha") ?? ""),
    render,
  });
  await saveMessage(Object.fromEntries(form), verdict.spam ? "spam" : "received", verdict.reasons);
  return redirect("/thanks", 303);
};
```

O handler guarda tudo com um status em vez de descartar o spam, e responde aos dois com o mesmo redirecionamento. Você revisa o spam depois, e um bot nunca descobre qual sinal o pegou. Adicione um [limite de taxa por visitante](https://ikiform.com/pt-br/guides/spam-protection/rate-limiting.md) na frente desta rota para barrar enxurradas antes que qualquer parte disso rode.

## O Ikiform roda isso sem captcha

Todo formulário do Ikiform roda o honeypot, o tempo, a pontuação de conteúdo e os limites de taxa sem configuração, e o **Spam check** dele começa em **Off**. O honeypot é `_gotcha`. A pontuação de conteúdo confere links, 22 frases de spam conhecidas, domínios de e-mail descartáveis e repetições idênticas em 24 horas. O limite de taxa começa em 5 por minuto por visitante. Com 5 ou mais, um envio é spam.

| Sinal | Peso |
| --- | --- |
| Honeypot preenchido | +5 |
| Enviado menos de 2 segundos após a renderização assinada | +3 |
| Horário de renderização forjado ou com mais de 24 horas | +3 |
| Um link num campo de nome ou assunto | +3 |
| Campos idênticos em 24 horas | +3 |
| Três ou mais links | +2 |
| Uma frase de spam conhecida | +2 |
| Um domínio de e-mail descartável | +2 |

O horário de renderização assinado do Ikiform vem do schema público do formulário. O [componente shadcn/ui](https://ikiform.com/pt-br/docs/shadcn.md) o envia por você. Um formulário que você mesmo renderiza pode buscá-lo com `getSchema()` de `@ikiform/sdk` e devolvê-lo como `_ikiform_ts`:

`contact.js`:

```js
import { getSchema, submit } from "@ikiform/sdk";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const form = document.querySelector("form");
const schema = await getSchema(FORM_ID);
const rendered = schema.ok ? schema.schema.ts : null;

form?.addEventListener("submit", async (event) => {
  event.preventDefault();
  const data = new FormData(form);
  if (rendered) data.set("_ikiform_ts", rendered);
  const result = await submit(FORM_ID, data);
  form.replaceWith(result.ok ? "Thanks, we'll be in touch." : result.error.message);
});
```

Formulários HTML simples não enviam horário de renderização e não são penalizados por isso, então a ausência dele não custa nada. Um envio julgado como spam recebe a mesma resposta de sucesso que um real, então o bot não aprende nada. Ele é guardado com o status `spam`, não dispara e-mail de notificação nem webhook, não conta no seu uso e é apagado após 30 dias. Toda segunda-feira, um workspace que bloqueou spam recebe um único e-mail de resumo com o total, os formulários mais visados e os principais motivos. O [endpoint de schema](https://ikiform.com/pt-br/docs/shadcn.md#schema-endpoint) e a [documentação de spam](https://ikiform.com/pt-br/docs/spam.md#scoring) têm os detalhes.

> Se alguns bots ainda passarem, mude **Spam check** para **Ikiform**. Isso adiciona o Cloudflare Turnstile sem chaves, e a maioria dos visitantes nem percebe. É esse o momento de adicionar um captcha: quando a pasta de spam pedir, não antes.

## Confira se funciona

1. Carregue o formulário e envie em menos de um segundo. Com seu próprio handler, o envio é guardado como spam com `too_fast`.
2. Edite o valor oculto `rendered` nas ferramentas de desenvolvedor do navegador e envie. Ele é pontuado como `invalid_render`.
3. Envie com o honeypot preenchido, `_gotcha=bot`. Ele ainda recebe uma resposta de sucesso, mas o envio é guardado como spam e não dispara e-mail nem webhook.
4. Envie uma mensagem com três links e nada mais suspeito. Ela é recebida.
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

- **Páginas estáticas.** Uma página gerada uma vez no deploy tem um único horário de renderização para todos os visitantes. Assine a cada requisição, ou busque um token novo com JavaScript quando o formulário aparecer.
- **Gerenciadores de senha e colar.** Algumas pessoas preenchem um formulário em um segundo com o preenchimento automático. Mantenha o tempo como um sinal leve, para que ele nunca bloqueie sozinho.
- **Listas de frases envelhecem.** As frases de spam mudam. Revise o que passa a cada poucas semanas e adicione a frase específica, nunca uma palavra comum.
- **Repetições são normais.** As pessoas reenviam quando não recebem resposta. Dê peso a uma repetição idêntica, mas não a bloqueie de vez.

## Perguntas

### Dá para acabar com o spam em formulários sem captcha?

Com a maior parte, sim. Um honeypot, um horário de renderização assinado, uma pontuação de conteúdo e um limite de taxa barram o grosso do que um site pequeno recebe, sem custo para visitantes reais. Bots que rodam um navegador completo e esperam ainda podem passar, e é aí que uma verificação invisível como o Turnstile ajuda.

### Quanto tempo uma armadilha de tempo deve esperar?

Uns três segundos para um formulário de contato. Menos que isso deixa passar bots rápidos; mais começa a pegar pessoas que colam uma mensagem pronta ou usam o preenchimento automático. Use como um sinal entre vários, não como bloqueio definitivo.

### Por que assinar o timestamp?

Um timestamp simples num campo oculto pode ser reescrito por qualquer bot para parecer mais antigo. Assiná-lo no servidor com um segredo significa que só o seu servidor consegue gerar um horário de renderização válido.

### O Ikiform precisa de captcha para filtrar spam?

Não. O honeypot, o tempo assinado, a pontuação de conteúdo e os limites de taxa rodam em todo formulário com a verificação de spam desligada. A verificação Turnstile gerenciada é um extra que você liga por formulário se o spam ainda passar.

## 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.
- [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.
- [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.
- [Cloudflare Turnstile](https://ikiform.com/pt-br/guides/spam-protection/cloudflare-turnstile.md): 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.
- [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.
