Honeypot em PHP contra bots em formulários
Olá meus Unicórnios! 🦄✨
Você monta um formulário de contato bonitinho, coloca no ar, vai dormir feliz. No dia seguinte tem trinta mensagens na caixa oferecendo backlink, criptomoeda e um serviço de SEO que ninguém pediu. 😅 Pois é.
A primeira reação de todo mundo é a mesma: vou botar um CAPTCHA. Só que o CAPTCHA cobra o preço no lugar errado. Quem paga é a pessoa de verdade, que tem de achar o semáforo na foto borrada, enquanto o bot moderno resolve aquilo melhor que você. E ainda entra uma dependência externa no site, com script de terceiro e tudo.
Existe um jeito mais velho, mais simples e, para o bot genérico que varre a web, mais eficaz: o honeypot, o pote de mel. Você deixa um campo na página que só um robô preencheria. Quem preenche, se entrega.
🍯 A ideia em uma frase
O bot que enche formulário pela internet afora não lê a sua página: ele lê o HTML. Encontra os <input>, preenche todos, e manda. É burro de propósito, porque precisa funcionar em milhões de formulários diferentes que ele nunca viu.
Então a armadilha é essa: você põe no HTML um campo que existe para o robô e não existe para a pessoa. A pessoa não vê, porque o CSS tirou da tela. O robô vê, porque para ele o CSS não existe. Chegou preenchido? Não era gente.
🚫 A armadilha que não funciona: type="hidden"
Esse é o erro que quase todo tutorial de honeypot ainda ensina, e é onde a maioria das implementações morre calada. Parece a escolha óbvia: quero um campo escondido, o HTML tem um campo escondido, pronto.
<!-- NÃO faça isto: o bot ignora e a armadilha nunca dispara -->
<input type="hidden" name="site" value="">
O problema é que o bot também conhece o type="hidden", e ele aprendeu a não tocar. É ali que moram token de CSRF, ID de registro, campo de controle: mexer naquilo é o jeito mais rápido de o envio ser recusado por motivo nenhum. Então o bot bem-comportado deixa quieto, manda o formulário, e a sua armadilha fica lá, vazia, achando que não passou bot nenhum.
A isca precisa parecer um campo comum no HTML e sumir só na tela. Quem esconde é o CSS, sempre.
🎨 O CSS que esconde do jeito certo
Aqui tem outra armadilha, e essa machuca gente de verdade. O reflexo é escrever display: none, e você não pode.
Dois motivos. O primeiro é que o display:none é fácil demais de detectar: o bot que se dá ao trabalho de olhar o CSS resolve isso em uma linha, e pula o campo. O segundo é mais sério: campo com display:none some da árvore de acessibilidade de um jeito e o campo fora da tela some de outro, e essa diferença importa quando você monta o resto da proteção.
O padrão que funciona é jogar o campo para fora da tela:
.isca {
position: absolute;
left: -9999px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
O campo continua existindo, continua sendo enviado com o formulário se alguém o preencher, e está a dez mil pixels à esquerda da primeira coluna da página. Nenhuma pessoa vidente vai encontrar aquilo por acaso.
♿ A parte que quase todo mundo esquece: quem usa leitor de tela
Se você só fizer o CSS acima e parar por aí, você acabou de criar uma armadilha para pessoas cegas. 😳
Pense em quem navega por áudio: o leitor de tela lê o documento, não a tela. Um campo fora da tela continua sendo um campo do formulário para ele, com rótulo e tudo. A pessoa ouve "Site, caixa de edição", digita o site dela porque parece um campo legítimo, e é descartada como bot. Silenciosamente, porque a resposta vai dizer que deu tudo certo.
Pense também em quem navega por teclado: aperta Tab depois do campo de mensagem e o foco cai num lugar invisível da página. A pessoa vê o foco sumir e não entende o que aconteceu.
São três defesas, e as três precisam estar juntas:
aria-hidden="true"no contêiner, que tira o campo da árvore de acessibilidade. O leitor de tela simplesmente não anuncia que ele existe.tabindex="-1"no<input>, que tira o campo da ordem do Tab. O foco nunca cai lá.- Um
<label>honesto dizendo "deixe este campo em branco", que é a rede de segurança: se alguma tecnologia assistiva furar as duas defesas acima, a pessoa ouve a instrução e sabe o que fazer.
E tem um quarto detalhe, pequeno e importante: autocomplete="off". Sem ele, o navegador da própria pessoa pode preencher a isca sozinho, porque o campo se chama algo como site e o autopreenchimento acha que está ajudando. Aí você descarta um visitante legítimo por culpa do navegador dele.
<!-- A armadilha. Sai da tela pelo CSS, nunca com type="hidden": o bot
ignora campo escondido e preenche tudo que parece um campo normal.
O aria-hidden tira do leitor de tela e o tabindex="-1" tira do Tab,
então quem navega por teclado ou por áudio nunca cai nela. -->
<div class="isca" aria-hidden="true">
<label for="site">Deixe este campo em branco</label>
<input type="text" id="site" name="site" value=""
autocomplete="off" tabindex="-1">
</div>
O nome do campo merece um segundo de atenção. Não chame de honeypot nem de isca, porque aí você está avisando o bot. Escolha um nome que soe apetitoso e comum: site, url, empresa, telefone2. Quanto mais parecer um campo que vale a pena preencher, melhor.
⏱️ A segunda armadilha: o cronômetro
A isca sozinha pega muita coisa, mas não pega tudo. Um bot escrito especificamente para o seu formulário aprende a deixar aquele campo quieto.
Aí entra a segunda medida, que é quase de graça: quanto tempo passou entre abrir a página e enviar o formulário?
Uma pessoa lê o rótulo, pensa no que escrever, digita, revisa. Isso são dezenas de segundos, no mínimo. Um bot faz o GET e o POST em sequência, na mesma respiração. Nos testes deste artigo o registro saiu literalmente como respondeu em 0s.
Três segundos já separa os dois mundos com folga.
E aqui mora a armadilha da armadilha: onde você guarda esse instante de abertura? O reflexo é pôr num campo escondido do formulário, junto com o resto. Só que campo escondido é dado que o bot controla: ele reescreve o valor para o que quiser e o seu cronômetro vira enfeite.
O instante tem de morar no servidor, na sessão:
// Guarda o instante em que a página foi aberta. Fica na sessão, no servidor:
// se ficasse num campo escondido, o bot poderia reescrever o valor.
if (empty($_SESSION['aberto_em'])) {
$_SESSION['aberto_em'] = time();
}
O navegador carrega só o cookie da sessão, que não diz nada sobre horário. Não tem o que falsificar.
🤫 O que fazer quando pegar um bot (a parte contraintuitiva)
Se você só for ler um pedaço deste artigo, leia este. 🙏
Pegou o bot. O reflexo agora é responder o que a lógica manda: "Erro: envio recusado". E é exatamente aí que a maioria das armadilhas se estraga sozinha, em algumas semanas, sem ninguém entender por quê.
Mensagem de erro é aula grátis. Quem mantém um bot de spam olha as respostas: se o seu site começou a recusar, ele tira um campo, tenta de novo, tira outro, tenta de novo, até passar. Você acabou de dar a ele um jeito de descobrir a sua armadilha por tentativa e erro, com resposta imediata a cada tentativa.
Faça o contrário. Responda a mesma mensagem de sucesso de sempre, palavra por palavra, e jogue a mensagem fora em silêncio:
if ($ehBot) {
// Responde SUCESSO, e não erro. Mensagem de erro ensina o bot a acertar
// na próxima tentativa; o silêncio deixa ele achando que funcionou.
$mensagemDeSucesso = 'Mensagem enviada, obrigada!';
}
O bot marca o seu formulário como "funcionou", vai embora e nunca mais volta para conferir. É o melhor desfecho possível: ele não insiste, não aprende e não te procura de novo.
📄 O arquivo inteiro
Um arquivo só, PHP puro, sem dependência nenhuma, que roda com o servidor embutido do PHP. É para ler de cima para baixo:
<?php
// Formulário de contato com armadilha (honeypot) para bots.
// Roda sozinho: php -S 127.0.0.1:8411 contato.php
session_start();
$TEMPO_MINIMO = 3; // segundos que um humano leva, no mínimo, para preencher
// Guarda o instante em que a página foi aberta. Fica na sessão, no servidor:
// se ficasse num campo escondido, o bot poderia reescrever o valor.
if (empty($_SESSION['aberto_em'])) {
$_SESSION['aberto_em'] = time();
}
$mensagemDeSucesso = '';
function registrarSuspeita($motivo)
{
$linha = date('Y-m-d H:i:s') . ' | bot descartado | ' . $motivo . "\n";
file_put_contents(__DIR__ . '/suspeitas.log', $linha, FILE_APPEND);
}
function guardarMensagem($nome, $email, $texto)
{
$linha = date('Y-m-d H:i:s') . ' | ' . $nome . ' | ' . $email . ' | ' . $texto . "\n";
file_put_contents(__DIR__ . '/mensagens.log', $linha, FILE_APPEND);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$nome = isset($_POST['nome']) ? trim($_POST['nome']) : '';
$email = isset($_POST['email']) ? trim($_POST['email']) : '';
$texto = isset($_POST['mensagem']) ? trim($_POST['mensagem']) : '';
$isca = isset($_POST['site']) ? trim($_POST['site']) : '';
$segundos = time() - (int) $_SESSION['aberto_em'];
$ehBot = false;
// Armadilha 1: o campo-isca tinha de chegar vazio. Humano nenhum o vê.
if ($isca !== '') {
$ehBot = true;
registrarSuspeita('preencheu o campo-isca com "' . $isca . '"');
}
// Armadilha 2: rápido demais. O bot responde no mesmo segundo.
if (!$ehBot && $segundos < $TEMPO_MINIMO) {
$ehBot = true;
registrarSuspeita('respondeu em ' . $segundos . 's (mínimo ' . $TEMPO_MINIMO . 's)');
}
if ($ehBot) {
// Responde SUCESSO, e não erro. Mensagem de erro ensina o bot a acertar
// na próxima tentativa; o silêncio deixa ele achando que funcionou.
$mensagemDeSucesso = 'Mensagem enviada, obrigada!';
} else if ($nome === '' || $email === '' || $texto === '') {
$mensagemDeSucesso = 'Erro: preencha o nome, o e-mail e a mensagem.';
} else if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$mensagemDeSucesso = 'Erro: esse e-mail não parece válido.';
} else {
guardarMensagem($nome, $email, $texto);
$mensagemDeSucesso = 'Mensagem enviada, obrigada!';
$_SESSION['aberto_em'] = time(); // zera o cronômetro para o próximo envio
}
}
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>Fale comigo</title>
<style>
.isca {
position: absolute;
left: -9999px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>
</head>
<body>
<?php if ($mensagemDeSucesso !== '') { ?>
<p><?php echo htmlspecialchars($mensagemDeSucesso, ENT_QUOTES, 'UTF-8'); ?></p>
<?php } ?>
<form method="post" action="">
<p><label for="nome">Nome</label><br>
<input type="text" id="nome" name="nome"></p>
<p><label for="email">E-mail</label><br>
<input type="email" id="email" name="email"></p>
<p><label for="mensagem">Mensagem</label><br>
<textarea id="mensagem" name="mensagem"></textarea></p>
<!-- A armadilha. Sai da tela pelo CSS, nunca com type="hidden": o bot
ignora campo escondido e preenche tudo que parece um campo normal.
O aria-hidden tira do leitor de tela e o tabindex="-1" tira do Tab,
então quem navega por teclado ou por áudio nunca cai nela. -->
<div class="isca" aria-hidden="true">
<label for="site">Deixe este campo em branco</label>
<input type="text" id="site" name="site" value=""
autocomplete="off" tabindex="-1">
</div>
<p><button type="submit">Enviar</button></p>
</form>
</body>
</html>
Repare em duas linhas fáceis de passar batido. A primeira é o !$ehBot && na armadilha do tempo: sem ele, um bot que caísse nas duas armadilhas geraria duas linhas no log para o mesmo envio, e a sua contagem de bots ficaria inflada.
A segunda é o $_SESSION['aberto_em'] = time() no final do caminho feliz. Sem ele, a pessoa que envia uma mensagem e logo em seguida quer enviar outra é barrada pelo cronômetro, porque a sessão ainda guarda a abertura da primeira página. Zerar ali é o que evita punir justamente quem mais interage com você.
🧪 Colocando para rodar
Suba o arquivo com o servidor embutido do PHP, numa porta qualquer que esteja livre:
php -S 127.0.0.1:8411 contato.php
Agora o bot. Um bot de formulário é, no fundo, um curl com pressa: ele busca a página e dispara o POST em seguida, preenchendo tudo que encontrou, inclusive a isca. O -c e o -b guardam e reenviam o cookie da sessão, que é o que faz o cronômetro do servidor valer para as duas requisições:
curl -s -c cookie-bot.txt -o /dev/null http://127.0.0.1:8411/
curl -s -b cookie-bot.txt -X POST http://127.0.0.1:8411/ \
-d "nome=Comprador Barato" -d "[email protected]" \
-d "mensagem=Visite meu site" -d "site=https://exemplo-spam.test"
E a pessoa, que é o mesmo curl com duas diferenças: espera cinco segundos e deixa a isca vazia.
curl -s -c cookie-humana.txt -o /dev/null http://127.0.0.1:8411/
sleep 5
curl -s -b cookie-humana.txt -X POST http://127.0.0.1:8411/ \
-d "nome=Paloma" -d "[email protected]" \
-d "mensagem=Oi, gostei do artigo" -d "site="
Os dois recebem a mesma resposta na tela. Essa é a graça da coisa: por fora, não dá para distinguir. A diferença só aparece nos dois arquivos de log.

Três envios descartados, um guardado. O mensagens.log tem só a linha da Paloma. O suspeitas.log mostra os dois jeitos de cair: dois bots pelo campo-isca e um pelo cronômetro, com o respondeu em 0s que é a assinatura de quem não é gente. 🎉
E note o que não aparece em lugar nenhum: uma mensagem de erro. Os três bots foram embora achando que a mensagem tinha sido entregue.
⚠️ Onde essa proteção não chega
Honeypot é excelente contra o bot genérico, aquele que varre a web preenchendo tudo que encontra. É de graça, não incomoda ninguém e não traz dependência nenhuma para dentro do seu site.
O que ele não é: proteção contra um ataque dirigido. Se alguém escrever um script olhando para o seu formulário especificamente, essa pessoa abre o HTML, vê a div.isca, deixa vazia, espera cinco segundos e passa. Contra isso, a isca é a primeira camada, não a única, e aí entram limite de envios por endereço e moderação antes de publicar.
A boa notícia é a proporção: a esmagadora maioria do lixo que chega num formulário de contato pequeno vem do bot genérico. Duas dúzias de linhas de PHP resolvem o grosso, e quem visita o seu site nunca fica sabendo que a armadilha existe. 🍯
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Perguntas frequentes
Por que não usar <code>type="hidden"</code> no campo-isca?
O leitor de tela não vai cair na armadilha?
aria-hidden="true" no contêiner tira o campo da árvore de acessibilidade, tabindex="-1" tira do caminho do Tab, e o CSS usa posicionamento fora da tela, nunca display:none. Um <label> dizendo "deixe este campo em branco" é a rede de segurança.Por que responder sucesso quando pego um bot?
Qual tempo mínimo de preenchimento devo usar?
respondeu em 0s), e nem o humano mais rápido digita nome, e-mail e mensagem em menos que isso. Se o formulário for de um campo só, baixe para 2.Por que o instante de abertura fica na sessão e não num campo escondido?
$_SESSION o instante mora no servidor, e o navegador só carrega o cookie da sessão, que não diz nada sobre a hora.Honeypot substitui CAPTCHA?
Leia também
API Mágica: notificações Web Push no navegador com PHP
Web Push com a API Mágica em PHP puro: o navegador se inscreve, o seu site envia a notificação e a API conta o clique. Sem Composer e sem biblioteca.
IndexNow: avisando o Bing e o Yandex mais rápido
Como criar a chave do IndexNow e avisar Bing e Yandex das suas páginas novas com Node.js e PHP: as requisições, os erros reais e as armadilhas.
llms.txt: por que importa e como gerar em PHP
Por que o llms.txt importa para quem quer ser citado pela IA, e como gerar o seu em PHP puro lendo o sitemap.xml que voce ja tem.