Acabe com o spam no formulário de contato6 min de leitura
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.
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.
- 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.
- 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.
- Uma mensagem real com três links soma 2 e nada mais, então é recebida.
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.
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:
---
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.
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 };
}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 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 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:
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 e a documentação de spam têm os detalhes.
Confira se funciona
- Carregue o formulário e envie em menos de um segundo. Com seu próprio handler, o envio é guardado como spam com
too_fast. - Edite o valor oculto
renderednas ferramentas de desenvolvedor do navegador e envie. Ele é pontuado comoinvalid_render. - 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. - Envie uma mensagem com três links e nada mais suspeito. Ela é recebida.
- 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
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.
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.
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.
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.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.