Arrêter le spam des formulaires de contact7 min de lecture
Comment ajouter un champ honeypot que les bots remplissent et pas les humains
Ajoutez à n'importe quel formulaire un honeypot qui attrape les bots sans gêner lecteurs d'écran, clavier ou remplissage automatique, avec la vérification serveur pour chaque stack.
Ajoutez un input texte supplémentaire que vous masquez hors écran, retirez de l'ordre de tabulation et englobez dans aria-hidden, avec un nom qu'aucun navigateur ne remplit automatiquement. Les personnes ne le remplissent jamais ; les bots qui remplissent chaque input, si. Côté serveur, traitez tout envoi où il a une valeur comme du spam et répondez exactement comme pour un vrai message.
Ce qui distingue un honeypot
Un honeypot doit être deux choses à la fois. Pour un bot qui lit le HTML, il doit ressembler à un champ normal qui mérite d'être rempli. Pour chaque personne, il ne doit pas exister, y compris pour celles qui n'utilisent pas le formulaire comme vous : quelqu'un avec un lecteur d'écran, quelqu'un qui navigue au clavier, quelqu'un dont le gestionnaire de mots de passe remplit les formulaires à sa place. La plupart des bugs de honeypot viennent de l'oubli d'une de ces personnes, et le symptôme est un vrai message discrètement classé en spam.
- Une personne remplit le formulaire avec ce qu'elle utilise : un écran, un clavier ou un lecteur d'écran.
- Le piège est hors écran, hors de l'ordre de tabulation et masqué aux technologies d'assistance : elle ne l'atteint jamais.
- Son nom est de ceux que les navigateurs et gestionnaires de mots de passe ne remplissent pas automatiquement : il reste donc vide.
- L'envoi arrive avec un piège vide et est reçu.
- Un bot scripté analyse le HTML et trouve chaque input, piège compris.
- Il les remplit tous et envoie aussitôt.
- Le piège a une valeur : l'envoi est donc du spam. Le bot reçoit quand même une réponse de succès normale.
Le balisage
Placez le piège dans le formulaire, à côté des vrais champs, sous forme d'input texte simple. Un input type="hidden" ne fonctionne pas : les bots savent que les personnes ne peuvent pas les modifier, et ne les touchent pas.
<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"sur le conteneur retire le label et l'input de l'arbre d'accessibilité : les lecteurs d'écran ne les annoncent donc pas.tabindex="-1"empêche la touche Tab de s'arrêter sur un input que personne ne voit.- Le label dit Leave this empty (laissez vide). Si un outil expose malgré tout le champ, la personne qui le lit sait quoi faire.
- N'ajoutez pas
required. Un champ obligatoire que personne ne voit bloque tout envoi honnête.
Comment le masquer
Les bots ont appris à ignorer les champs manifestement masqués. Les scrapers simples remplissent encore tout, mais certains vérifient l'attribut hidden ou un display: none inline avant de taper. Plus le masquage est éloigné de l'input lui-même, plus les bots s'y laissent prendre.
| Technique | Masqué aux personnes | Bots encore attrapés |
|---|---|---|
Attribut hidden ou display: none inline | Oui | Seulement les plus simples. C'est la première chose qu'un bot plus malin vérifie |
| Classe hors écran dans votre feuille de style | Oui | La plupart. Le bot doit charger et appliquer votre CSS pour le savoir |
opacity: 0 ou taille nulle | En grande partie. Certaines loupes et contours de focus le révèlent encore | La plupart, mais plus risqué pour les personnes |
| Ajouté par JavaScript après le chargement | Oui | Les bots qui exécutent des scripts. Les scrapers HTML simples ne le voient jamais : il les rate donc |
Utilisez la classe hors écran de votre feuille de style. Elle attrape le plus de bots et c'est la plus sûre pour les personnes. Si un honeypot cesse de fonctionner, ajoutez-en un second avec un autre nom plutôt qu'une astuce de masquage plus subtile.
Choisir un nom que le remplissage automatique ignore
Les navigateurs et les gestionnaires de mots de passe remplissent les champs en devinant d'après leur name, leur id, leur label et leur valeur autocomplete. autocomplete="off" n'est qu'une indication, et certains navigateurs l'ignorent pour les champs qui ressemblent à des adresses ou des coordonnées. C'est donc le nom qui fait le vrai travail.
| À éviter | Pourquoi | À utiliser à la place |
|---|---|---|
email2, phone, name, address | Le remplissage automatique les reconnaît et les remplit pour de vraies personnes | _gotcha, company_website |
honeypot, trap, bot | Certains bots ignorent les champs aux noms évidents | Un nom banal et plausible |
url ou website seuls | Certains outils de remplissage automatique remplissent un champ site web à partir d'un profil enregistré | Un nom composé comme company_website |
Le vérifier sur le serveur
Un honeypot ne fonctionne que si le serveur le lit. Une vérification côté navigateur ne sert à rien, puisqu'un bot n'exécute jamais votre script. Quand le champ a une valeur, répondez exactement comme pour un vrai message, et ne stockez ni ne transmettez l'envoi. Une réponse différente indique au bot quel champ l'a trahi. Les exemples ci-dessous appellent votre propre saveMessage ; remplacez-le par ce qui stocke ou envoie le message par e-mail.
Un formulaire HTML simple n'a pas de serveur à lui. Faites-le pointer vers Ikiform et nommez le piège _gotcha, ou renommez-le pour chaque formulaire sous Honeypot field. Ikiform fait la vérification serveur, et le piège n'est jamais stocké comme réponse.
<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>Dans une server action, redirigez un envoi piégé vers la même page de remerciement qu'un vrai, et sautez l'enregistrement.
"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");
}Dans une Astro Action, retirez le piège de l'input et renvoyez le résultat normal quand il a une valeur.
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 };
},
}),
};Lisez le piège dans .validator() et sautez l'enregistrement dans le handler. Les deux chemins renvoient la même réponse.
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 };
});L'action de la route redirige vers la page de remerciement dans les deux cas et n'enregistre que les envois non piégés.
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");
}La form action n'enregistre que si le piège est vide, puis redirige avec 303 dans les deux cas.
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;La server route répond 201 avec le même corps pour un envoi piégé et pour un vrai.
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 };
});Une application monopage ne peut pas vérifier le piège elle-même. Envoyez-le avec les autres champs, et laissez décider le serveur qui reçoit l'envoi. FormData l'inclut sans code supplémentaire.
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});Même chose en Vue : gardez le piège dans le <form> pour que new FormData(form) l'inclue, et laissez le serveur le vérifier.
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});Comment Ikiform gère le honeypot
Chaque formulaire Ikiform a par défaut un honeypot nommé _gotcha. Changez-le pour chaque formulaire sous Honeypot field dans les réglages du formulaire ; le nom choisi est réservé et n'est jamais stocké comme réponse. Un honeypot rempli ajoute 5 au score de spam, ce qui atteint à lui seul le seuil de 5.
Ikiform ne jette pas l'envoi. Il le garde comme spam : si une vraie personne déclenche un jour le piège, vous pouvez retrouver le message et le restaurer. Un envoi jugé comme spam reçoit la même réponse de succès qu'un vrai : un bot n'apprend donc rien. Il est stocké avec le statut spam, n'envoie ni e-mail de notification ni webhook, ne compte pas dans votre consommation, et est supprimé au bout de 30 jours. Chaque lundi, un espace de travail qui a bloqué du spam reçoit un e-mail récapitulatif avec le total, les formulaires les plus sollicités et les principales raisons. La documentation sur le spam et celle des formulaires HTML servent de référence.
Vérifiez que tout fonctionne
- Chargez le formulaire avec un lecteur d'écran activé et parcourez-le. Le piège n'est jamais annoncé.
- Parcourez le formulaire avec Tab. Le focus passe du dernier vrai champ directement au bouton.
- Laissez votre navigateur et votre gestionnaire de mots de passe remplir automatiquement le formulaire. Le piège reste vide.
- Envoyez avec le honeypot rempli,
_gotcha=bot. L'envoi reçoit quand même une réponse de succès, mais la soumission est stockée comme spam et n'envoie ni e-mail ni webhook. - Envoyez un message normal. Il arrive comme reçu, sans trace du champ piège.
Pièges
- Envois JSON. Si vous construisez le corps à la main au lieu d'utiliser
FormData, incluez le piège, sinon votre serveur le lira comme vide pour les bots aussi. - Créateurs de formulaires qui renomment les champs. Certains outils préfixent ou renomment les inputs. Vérifiez le vrai nom du piège dans le corps envoyé, pas dans l'éditeur.
- Cache. Si une page en cache garde l'ancien nom du piège après que vous l'avez renommé, les envois arrivent avec l'ancien nom et passent. Renommez des deux côtés en même temps.
- Honeypots en case à cocher. Une case à cocher masquée fonctionne aussi, mais les lecteurs d'écran gèrent moins bien les cases isolées que les inputs texte. Préférez un input texte.
- Une seule couche. Les bots qui pilotent un vrai navigateur et ignorent les champs invisibles passent outre tout honeypot. Associez-le à une limite de débit et, si besoin, à une vérification humaine.
Questions
Oui, contre les plus courants : les scripts qui remplissent chaque input trouvé. Les bots qui pilotent un navigateur complet et ignorent les champs invisibles passent : utilisez donc un honeypot comme première couche, pas comme la seule.
Seulement s'il est masqué à la légère. Englobez-le dans un élément avec aria-hidden, donnez à l'input tabindex -1, mettez le label Leave this empty, et ne le rendez jamais obligatoire. Les lecteurs d'écran et le clavier l'ignorent alors.
En général, à cause du remplissage automatique. Un navigateur ou un gestionnaire de mots de passe a rempli le piège parce que son nom ressemblait à un vrai champ, comme email2 ou phone. Renommez-le en quelque chose qu'aucun remplissage automatique ne reconnaît.
Non. Répondez à un envoi piégé exactement comme à un vrai. Une erreur indique au bot quel champ l'a attrapé, et la version suivante du bot ignore ce champ.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.