Acabe com o spam no formulário de contato6 min de leitura
Como adicionar um campo honeypot que bots preenchem e pessoas não
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.
Adicione um campo de texto extra que você esconde fora da tela, tira da ordem de tabulação e envolve em aria-hidden, com um nome que nenhum navegador preenche automaticamente. Pessoas nunca o preenchem; bots que preenchem todos os campos, sim. No servidor, trate qualquer envio com valor nele como spam e responda exatamente como faria com uma mensagem real.
O que um honeypot tem de diferente
Um honeypot precisa ser duas coisas ao mesmo tempo. Para um bot lendo o HTML, deve parecer um campo normal que vale a pena preencher. Para toda pessoa, não pode existir, e isso inclui quem não usa o formulário do mesmo jeito que você: alguém com leitor de tela, alguém navegando pelo teclado, alguém cujo gerenciador de senhas preenche formulários por ele. A maioria dos bugs de honeypot vem de esquecer uma dessas pessoas, e o sintoma é uma mensagem real arquivada como spam sem alarde.
- Uma pessoa preenche o formulário com o que costuma usar: tela, teclado ou leitor de tela.
- A armadilha está fora da tela, fora da ordem de tabulação e oculta das tecnologias assistivas, então ela nunca chega até lá.
- O nome dela é um que navegadores e gerenciadores de senha não preenchem, então ela fica vazia.
- O envio chega com a armadilha vazia e é recebido.
- Um bot de script lê o HTML e encontra todos os campos, inclusive a armadilha.
- Ele preenche todos e envia na hora.
- A armadilha tem valor, então o envio é spam. O bot ainda recebe uma resposta de sucesso normal.
O markup
Coloque a armadilha dentro do formulário, ao lado dos campos reais, como um campo de texto comum. Um campo type="hidden" não funciona: os bots sabem que pessoas não podem editá-lo e o deixam em paz.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<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" required></textarea>
<div class="hp" aria-hidden="true">
<label for="hp-field">Leave this empty</label>
<input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<button>Send</button>
</form><style>
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>aria-hidden="true"no contêiner remove o rótulo e o campo da árvore de acessibilidade, então leitores de tela não os anunciam.tabindex="-1"impede que a tecla Tab pare num campo que ninguém vê.- O rótulo diz Leave this empty (deixe vazio). Se alguma ferramenta ainda expuser o campo, quem o ler sabe o que fazer.
- Não adicione
required. Um campo obrigatório que as pessoas não veem bloqueia todo envio honesto.
Como escondê-lo
Os bots aprenderam a pular campos obviamente ocultos. Scrapers simples ainda preenchem tudo, mas alguns conferem o atributo hidden ou um display: none inline antes de digitar. Quanto mais longe do próprio campo estiver o que o esconde, mais bots caem.
| Técnica | Oculto para pessoas | Bots que ainda pega |
|---|---|---|
Atributo hidden ou display: none inline | Sim | Só os mais simples. É a primeira coisa que um bot mais esperto confere |
| Classe fora da tela na sua folha de estilo | Sim | A maioria. O bot precisa carregar e aplicar seu CSS para perceber |
opacity: 0 ou tamanho zero | Quase sempre. Algumas lupas e contornos de foco ainda o revelam | A maioria, mas é mais arriscado para pessoas |
| Adicionado por JavaScript após o carregamento | Sim | Bots que rodam scripts. Scrapers de HTML simples nunca o veem, então ele os deixa passar |
Use a classe fora da tela da sua folha de estilo. Ela pega mais bots e é a mais segura para pessoas. Se um honeypot parar de funcionar, adicione um segundo com outro nome em vez de um truque de esconder mais esperto.
Escolha um nome que o preenchimento automático ignore
Navegadores e gerenciadores de senha preenchem campos adivinhando pelo name, id, rótulo e valor de autocomplete. autocomplete="off" é só uma dica, e alguns navegadores a ignoram em campos que parecem endereços ou dados de contato. Então quem faz o trabalho de verdade é o nome.
| Evite | Por quê | Use no lugar |
|---|---|---|
email2, phone, name, address | O preenchimento automático os reconhece e os preenche para pessoas reais | _gotcha, company_website |
honeypot, trap, bot | Alguns bots pulam campos com nomes óbvios | Um nome sem graça e plausível |
url, website sozinhos | Algumas ferramentas de preenchimento automático preenchem um campo de site a partir de um perfil salvo | Um nome composto como company_website |
Confira no servidor
Um honeypot só funciona se o servidor o ler. Uma verificação no navegador é inútil, já que um bot nunca roda seu script. Quando o campo tiver valor, responda exatamente como faria com uma mensagem real e não guarde nem repasse o envio. Uma resposta diferente diz ao bot qual campo o denunciou. Os exemplos abaixo chamam a sua própria saveMessage; troque pelo que guarda ou envia a mensagem por e-mail.
Um formulário HTML simples não tem servidor próprio. Aponte-o para o Ikiform e chame a armadilha de _gotcha, ou renomeie por formulário em Honeypot field. O Ikiform faz a verificação no servidor, e a armadilha nunca é guardada como resposta.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<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" required></textarea>
<div class="hp" aria-hidden="true">
<label for="hp-field">Leave this empty</label>
<input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<button>Send</button>
</form>Numa server action, redirecione um envio pego na armadilha para a mesma página de agradecimento que um real recebe, e pule o salvamento.
"use server";
import { redirect } from "next/navigation";
export async function sendMessage(formData: FormData) {
if (String(formData.get("company_website") ?? "").trim() !== "") {
console.info("honeypot", { at: new Date().toISOString() });
redirect("/contact/thanks");
}
await saveMessage({
email: String(formData.get("email") ?? ""),
message: String(formData.get("message") ?? ""),
});
redirect("/contact/thanks");
}Numa Astro Action, tire a armadilha do input e retorne o resultado normal quando ela tiver valor.
import { defineAction } from "astro:actions";
import { z } from "astro/zod";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({
email: z.email(),
message: z.string().min(1),
company_website: z.string().nullish(),
}),
handler: async ({ company_website, ...fields }) => {
if (company_website?.trim()) return { sent: true };
await saveMessage(fields);
return { sent: true };
},
}),
};Leia a armadilha em .validator() e pule o salvamento no handler. Os dois caminhos retornam a mesma resposta.
import { createServerFn } from "@tanstack/react-start";
export const sendMessage = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return {
email: String(data.get("email") ?? ""),
message: String(data.get("message") ?? ""),
trapped: String(data.get("company_website") ?? "").trim() !== "",
};
})
.handler(async ({ data: { trapped, ...fields } }) => {
if (!trapped) await saveMessage(fields);
return { ok: true };
});A action da rota redireciona para a página de agradecimento nos dois casos e só salva um envio que não caiu na armadilha.
import { redirect } from "react-router";
import type { Route } from "./+types/contact";
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
if (String(form.get("company_website") ?? "").trim()) return redirect("/contact/thanks");
await saveMessage({ email: String(form.get("email")), message: String(form.get("message")) });
return redirect("/contact/thanks");
}A form action só salva quando a armadilha está vazia e depois redireciona com 303 nos dois casos.
import { redirect } from "@sveltejs/kit";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request }) => {
const form = await request.formData();
if (!String(form.get("company_website") ?? "").trim()) {
await saveMessage({ email: String(form.get("email")), message: String(form.get("message")) });
}
redirect(303, "/contact/thanks");
},
} satisfies Actions;A server route responde 201 com o mesmo corpo para um envio pego na armadilha e para um real.
export default defineEventHandler(async (event) => {
const body = await readBody<{ email?: string; message?: string; company_website?: string }>(event);
if (body.company_website?.trim()) {
setResponseStatus(event, 201);
return { sent: true };
}
await saveMessage({ email: body.email ?? "", message: body.message ?? "" });
setResponseStatus(event, 201);
return { sent: true };
});Um app de página única não consegue conferir a armadilha sozinho. Envie-a com os outros campos e deixe o servidor para onde você envia decidir. FormData a inclui sem código extra.
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});Igual no Vue: mantenha a armadilha dentro do <form> para que new FormData(form) a inclua, e deixe o servidor conferi-la.
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});Como o Ikiform lida com o honeypot
Todo formulário do Ikiform tem por padrão um honeypot chamado _gotcha. Mude-o por formulário em Honeypot field nas configurações do formulário; o nome que você escolher fica reservado e nunca é guardado como resposta. Um honeypot preenchido soma 5 à pontuação de spam, o que já atinge sozinho o limite de 5.
O Ikiform não descarta o envio. Ele o mantém como spam, então se uma pessoa real cair na armadilha, você pode achar a mensagem e restaurá-la. 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. A documentação de spam e a de formulários HTML têm a referência.
Confira se funciona
- Carregue o formulário com um leitor de tela ligado e percorra-o. A armadilha nunca é anunciada.
- Navegue pelo formulário com Tab. O foco vai do último campo real direto para o botão.
- Deixe seu navegador e seu gerenciador de senhas preencherem o formulário automaticamente. A armadilha continua vazia.
- 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 normal. Ela chega como recebida, sem nenhum vestígio do campo armadilha.
Pegadinhas
- Envios em JSON. Se você monta o corpo à mão em vez de usar
FormData, inclua a armadilha, ou seu servidor também a lê como vazia para os bots. - Construtores de formulário que renomeiam campos. Alguns construtores colocam prefixo ou renomeiam os campos. Confira o corpo enviado, não o editor, para saber o nome real da armadilha.
- Cache. Se uma página em cache ainda tiver o nome antigo da armadilha depois que você o renomear, os envios chegam com o nome antigo e passam. Renomeie dos dois lados ao mesmo tempo.
- Honeypots de checkbox. Uma checkbox oculta também funciona, mas leitores de tela lidam pior com checkboxes soltas do que com campos de texto. Prefira um campo de texto.
- Uma camada só. Bots que rodam um navegador real e pulam campos invisíveis passam por qualquer honeypot. Combine-o com um limite de taxa e, se precisar, uma verificação humana.
Perguntas
Sim, contra os mais comuns: scripts que preenchem todos os campos que encontram. Bots que rodam um navegador completo e pulam campos invisíveis passam, então use o honeypot como primeira camada, não a única.
Só se for escondido sem cuidado. Envolva-o num elemento com aria-hidden, dê tabindex -1 ao campo, use o rótulo Leave this empty e nunca o marque como obrigatório. Assim leitores de tela e teclados o pulam.
Geralmente é o preenchimento automático. Um navegador ou gerenciador de senhas preencheu a armadilha porque o nome dela parecia de um campo real, como email2 ou phone. Renomeie-a para algo que nenhum preenchimento automático reconheça.
Não. Responda a um envio pego na armadilha exatamente como a um real. Um erro diz ao bot qual campo o pegou, e a próxima versão do bot pula esse campo.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.