Arrêter le spam des formulaires de contact7 min de lecture
Comment arrêter le spam sur le formulaire de contact d'un site statique
Arrêtez le spam d'un formulaire de site statique, même sans secret ni compteur, avec un partial honeypot, un script sûr et des vérifications côté serveur.
Un site statique n'a pas de serveur : rien sur la page ne peut donc garder une clé secrète, vérifier un token de captcha ou compter les envois par visiteur. Ces vérifications doivent tourner ailleurs : une fonction chez votre hébergeur, ou un backend de formulaires vers lequel votre formulaire envoie. Dans les deux cas, placez un honeypot accessible dans le formulaire, et gardez-le fonctionnel sans JavaScript.
Ce qui change sur un site statique
Sur un site statique, chaque octet déployé est public. Tout ce qui est dans votre HTML ou votre JavaScript peut être lu et contourné par un bot : les vérifications anti-spam côté client sont donc des conseils, pas des contraintes. Les trois vérifications qui comptent le plus ont toutes besoin d'un serveur :
- Vérifier un token de captcha demande une clé secrète, qui ne peut pas vivre dans un fichier public.
- Compter les envois par visiteur demande un stockage qui survit d'une requête à l'autre.
- Lire un honeypot demande du code qui s'exécute après l'arrivée de l'envoi, hors de portée du bot.
Les hébergeurs statiques n'acceptent pas non plus les envois vers vos pages. Vercel et GitHub Pages répondent à un POST de formulaire vers une page statique par 405 Method Not Allowed : le formulaire a donc besoin d'un endpoint avant même que la question du spam ne se pose.
- Une page statique qui envoie vers elle-même atteint un hébergeur qui n'exécute aucun code : l'envoi échoue avec 405 ou se perd.
- Avec une fonction chez l'hébergeur, la page envoie vers une fonction qui détient le secret, lit le honeypot et vérifie le token, puis transmet le message.
- Avec un backend de formulaires, la page envoie directement au backend, qui exécute le honeypot, le score, la limite de débit et la vérification humaine avant de vous envoyer l'e-mail.
Vos options
| Option | Vérifications anti-spam | Compromis |
|---|---|---|
| Netlify Forms | Akismet sur chaque envoi, plus un champ netlify-honeypot et reCAPTCHA v2 si vous les ajoutez | Uniquement sur Netlify. Changer d'hébergeur oblige à reconstruire le formulaire. Voir les alternatives à Netlify Forms |
| Une fonction chez votre hébergeur | Ce que vous écrivez : honeypot, vérification de token, limite de débit avec un stockage | Vous l'écrivez et la maintenez, et il vous faut un service d'e-mail ou de stockage derrière |
| Un backend de formulaires, comme Ikiform | Honeypot, score, limites de débit, domaines autorisés et vérification humaine, sans code | Un service de plus, facturé à la soumission |
Le formulaire, en partial réutilisable
Quel que soit l'endpoint choisi, écrivez le formulaire une seule fois sous forme de partial ou d'include, pour que chaque page utilise le même honeypot. La version ci-dessous envoie vers Ikiform. Pour utiliser une fonction, changez action pour le chemin de la fonction et retirez _redirect.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" class="contact">
<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" rows="5" 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>
<input type="hidden" name="_redirect" value="{{ "thanks/" | absURL }}" />
<p role="status"></p>
<button>Send message</button>
</form><style>
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>- Le honeypot est placé hors écran, hors de l'ordre de tabulation et masqué aux lecteurs d'écran. Le guide du honeypot explique chaque attribut.
_redirectenvoie les gens vers votre page de remerciement. Ikiform ne le suit que pour une adressehttps:sur l'un des Allowed domains du formulaire.- Les guides Hugo, Jekyll et Eleventy couvrent la mise en place, les shortcodes et GitHub Pages.
L'améliorer sans le casser
Un script peut garder les visiteurs sur la page, mais il ne doit jamais être le seul moyen pour le formulaire de fonctionner. Celui-ci envoie avec fetch et se rabat sur un envoi normal du navigateur dans deux cas : quand l'appel réseau échoue, et quand la vérification humaine d'Ikiform demande un token avec challenge_required. Un envoi normal reçoit alors la page de vérification d'Ikiform : vous pouvez donc activer la vérification sans changer le script.
<script>
document.querySelectorAll("form.contact").forEach((form) => {
const status = form.querySelector("[role=status]");
const button = form.querySelector("button");
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true;
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
form.replaceChildren("Thanks. We'll reply by email.");
return;
}
if (result.error.code === "challenge_required") {
form.submit();
return;
}
const wait = response.headers.get("Retry-After");
if (response.status === 429 && wait) {
status.textContent = `Too many messages. Try again in ${wait} seconds.`;
} else {
status.textContent = result.error.message;
}
} catch {
form.submit();
return;
}
button.disabled = false;
});
});
</script>form.submit()envoie le formulaire sans redéclencher l'événementsubmit: le repli ne peut donc pas boucler.- Un
429indique combien de temps attendre d'aprèsRetry-After, qu'Ikiform expose au code du navigateur. - La ligne de succès remplace le formulaire : personne ne l'envoie donc deux fois par accident.
Si vous écrivez une fonction chez l'hébergeur
Netlify, Cloudflare Pages et Vercel exécutent tous de petites fonctions à côté des fichiers statiques. Une fonction est votre propre backend : c'est donc elle qui porte les vérifications anti-spam. Cette Netlify Function lit le honeypot, écarte les messages bourrés de liens, vérifie un token Turnstile et vous envoie un e-mail via Resend. Chaque envoi attrapé reçoit la même redirection de remerciement qu'un vrai.
import type { Config, Context } from "@netlify/functions";
const LINK = /https?:\/\/|www\./gi;
export default async (req: Request, context: Context) => {
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
const form = await req.formData();
const email = String(form.get("email") ?? "");
const message = String(form.get("message") ?? "");
const thanks = Response.redirect(new URL("/thanks/", req.url), 303);
if (String(form.get("_gotcha") ?? "").trim()) return thanks;
if ((message.match(LINK)?.length ?? 0) >= 3) return thanks;
const check = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: new URLSearchParams({
secret: process.env.TURNSTILE_SECRET_KEY ?? "",
response: String(form.get("cf-turnstile-response") ?? ""),
remoteip: context.ip,
}),
});
const verdict = (await check.json()) as { success: boolean };
if (!verdict.success) return Response.redirect(new URL("/contact/?error=check", req.url), 303);
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
from: "Contact form <form@example.com>",
to: "you@example.com",
reply_to: email,
subject: "New message from the website",
text: message,
}),
});
return thanks;
};
export const config: Config = { path: "/api/contact" };context.ipest l'IP du visiteur, que siteverify peut utiliser commeremoteip.- Définissez
TURNSTILE_SECRET_KEYetRESEND_API_KEYdans les variables d'environnement du site, jamais dans le dépôt. - Elle jette les envois attrapés au lieu de les stocker : un vrai message attrapé par erreur est donc perdu. Journalisez-les au moins.
- Elle n'a pas de limite de débit, puisqu'une fonction n'a pas de mémoire d'un appel à l'autre. Le guide sur la limitation de débit en ajoute une avec Redis.
Ce que fait Ikiform pour un formulaire statique
Avec un formulaire qui envoie directement vers Ikiform, le travail côté serveur se fait là-bas. Le honeypot est _gotcha, le score du contenu et les limites de débit par visiteur s'exécutent sur chaque envoi, et Allowed domains marque comme spam les envois venant d'autres sites. Réglez Spam check sur Ikiform pour Cloudflare Turnstile sans clés : l'envoi HTML simple l'exécute sur une page de vérification, et le script ci-dessus se rabat sur cette page. 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.
username.github.io. Les deploy previews de Netlify et les URL de préproduction de Vercel ne sont pas des sous-domaines de l'adresse de votre site principal ; ajoutez l'hôte exact de la préproduction pendant que vous la testez.Vérifiez que tout fonctionne
- Désactivez JavaScript et envoyez. Le formulaire est quand même envoyé et vous arrivez sur votre page de remerciement.
- Activez JavaScript et envoyez. Le formulaire est remplacé par la ligne de remerciement.
- 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. - Réglez Spam check sur Ikiform et envoyez avec le script actif. La page se rabat sur un envoi normal et affiche la page de vérification.
- Rien n'est arrivé ? Passez le filtre de statut du tableau de bord sur Spam. Le spam reçoit la même réponse de succès qu'une vraie soumission.
Pièges
403 origin_not_allowed, ou chaque envoi atterrit dans le spam. Le domaine de la page n'est pas dans les Allowed domains du formulaire. Ajoutez-le, ainsi quelocalhostpour les tests en local. Les sous-domaines d'un domaine listé sont autorisés.- HTML minifié. Certaines étapes de build retirent les attributs qu'elles jugent vides ou inutilisés. Vérifiez que la page construite a toujours
aria-hiddenettabindexsur le honeypot. - Pages de remerciement en cache. Si la page de remerciement a la même URL pour le spam et les vrais envois, ce qui doit être le cas, n'y mettez jamais rien qui suppose que l'envoi est réel.
- Clés dans le dépôt. Un secret commité dans le dépôt public d'un site statique est public. Renouvelez-le si cela s'est déjà produit.
Questions
Vous pouvez afficher le widget, mais son token doit être vérifié avec une clé secrète sur un serveur. Utilisez une fonction chez votre hébergeur, un backend de formulaires qui vérifie vos clés, ou la vérification gérée d'Ikiform, qui ne demande aucune clé.
Il arrête les bots les plus courants, mais seulement si quelque chose le lit sur un serveur. Associez-le à une limite de débit et à un score du contenu, qui demandent aussi un serveur ou un backend de formulaires.
Le formulaire envoie vers une page chez un hébergeur qui ne sert que des fichiers. Faites plutôt pointer l'action du formulaire vers une fonction ou l'endpoint d'un backend de formulaires.
Il le devrait. Envoyez d'abord avec un formulaire HTML simple, et ajoutez un script qui l'améliore et se rabat sur un envoi normal en cas de problème.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.