Arrêter le spam des formulaires de contact

Arrêter le spam des formulaires de contact7 min de lecture

Comment ajouter Cloudflare Turnstile à un formulaire HTML

Ajoutez Cloudflare Turnstile à un formulaire HTML simple : le widget, siteverify côté serveur, les clés de test, et une façon de l'utiliser sans clés ni serveur à vous.

Preet SutharFondateur, Ikiform

Ajoutez le script de Turnstile et un élément cf-turnstile dans votre formulaire ; le widget place un token dans un champ masqué cf-turnstile-response. Envoyez ensuite ce token et votre clé secrète à l'endpoint siteverify de Cloudflare depuis un serveur, et n'acceptez l'envoi que s'il répond success. Sans serveur, laissez votre backend de formulaires le vérifier, ou utilisez la vérification gérée d'Ikiform, qui ne demande aucune clé.

Ce qui change avec Turnstile

Turnstile a deux moitiés, et un formulaire HTML simple n'a de place que pour une. Le widget tourne dans le navigateur avec votre site key publique et produit un token. La vérification tourne sur un serveur avec votre secret key, qui ne doit jamais atteindre la page. Un formulaire qui affiche le widget sans jamais vérifier le token n'est que décoration : un bot envoie directement vers votre endpoint et ignore complètement le widget.

La vraie question pour un formulaire HTML est donc de savoir où vit le secret. Cette page couvre trois réponses : votre propre petit serveur, un backend de formulaires qui détient vos clés, et une vérification gérée où personne d'autre que le backend ne détient de clés.

La vie d'un token Turnstile, du widget au verdictDans le navigateur, avec la site keySur un serveur, avec la clé secrètele widget tournegénéralementinvisibletoken émisinput masquécf-turnstile-responseenvoi duformulairele token suit leschampssiteverifysecret + token + IPdu visiteurvérifier laréponsesuccess, hostname,actionaccepter l'envoirefuserenvoitout correspondquelque chose clocheLa vie d'un token Turnstile, du widget au verdictDans le navigateur, avec la site keySur un serveur, avec la clé secrètele widget tournegénéralementinvisibletoken émisinput masquécf-turnstile-responseenvoi duformulairele token suit leschampssiteverifysecret + token + IPdu visiteurvérifier laréponsesuccess, hostname,actionaccepter l'envoirefuserenvoitout correspondquelque chose cloche
  1. Le widget Turnstile tourne dans le navigateur avec votre site key publique. La plupart des visiteurs ne le voient jamais rien faire.
  2. Il émet un token et le place dans un input masqué cf-turnstile-response à l'intérieur du formulaire.
  3. Le formulaire est envoyé, et le token voyage avec les autres champs.
  4. Votre serveur envoie la clé secrète, le token et l'IP du visiteur à l'endpoint siteverify de Cloudflare.
  5. Il vérifie que la réponse indique success et que le hostname et l'action sont ceux attendus.
  6. Si tout correspond, l'envoi est accepté. Si quelque chose cloche, il est refusé.
Un token est valable 300 secondes et ne peut être vérifié qu'une fois. Une seconde vérification du même token échoue avec timeout-or-duplicate.

Ajouter le widget

Créez un widget dans le tableau de bord Cloudflare sous Turnstile, listez les hostnames où tourne votre formulaire, et copiez la site key. Ajoutez ensuite le script et un élément cf-turnstile dans le formulaire. L'élément doit être à l'intérieur du <form>, sinon le champ masqué du token se retrouve en dehors et n'est jamais envoyé.

contact.html
<form action="https://example.com/api/contact" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />
  <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>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
  <button>Send message</button>
</form>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
  • data-action="contact" étiquette le widget. Siteverify le renvoie : votre serveur peut donc refuser un token émis pour un autre formulaire.
  • Le script doit être chargé depuis l'URL de Cloudflare. D'après Cloudflare, une copie passée par un proxy ou auto-hébergée casse le widget.
  • Si votre site envoie une Content Security Policy, autorisez https://challenges.cloudflare.com dans script-src et dans frame-src.
  • Sans frais, Cloudflare autorise jusqu'à 20 widgets, chacun sur jusqu'à 10 hostnames : listez donc aussi les hôtes de préproduction.

Vérifier le token sur votre propre serveur

N'importe quel serveur peut vérifier un token : un POST vers siteverify avec le secret et le token. Le voici sous forme de Cloudflare Worker vers lequel un formulaire statique envoie. Il vérifie le token, le hostname et l'action, retire le token, et transmet les champs à Ikiform avec une clé secrète. Remplacez cette transmission par tout autre moyen de stocker ou d'envoyer le message par e-mail.

src/index.ts
interface Env {
  TURNSTILE_SECRET_KEY: string;
  IKIFORM_SECRET_KEY: string;
}

interface Verdict {
  success: boolean;
  hostname?: string;
  action?: string;
  "error-codes"?: string[];
}

const SITE = "https://example.com";
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
    const form = await request.formData();

    const check = await fetch(SITEVERIFY, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        secret: env.TURNSTILE_SECRET_KEY,
        response: String(form.get("cf-turnstile-response") ?? ""),
        remoteip: request.headers.get("CF-Connecting-IP"),
        idempotency_key: crypto.randomUUID(),
      }),
    });
    const verdict = (await check.json()) as Verdict;
    const expected = verdict.hostname === new URL(SITE).hostname && verdict.action === "contact";
    if (!verdict.success || !expected) {
      console.warn("turnstile", verdict["error-codes"] ?? [], verdict.hostname, verdict.action);
      return Response.redirect(`${SITE}/contact?error=check`, 303);
    }

    form.delete("cf-turnstile-response");
    const sent = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Accept: "application/json",
        Authorization: `Bearer ${env.IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("Origin") ?? SITE,
      },
      body: form,
    });
    return Response.redirect(sent.ok ? `${SITE}/thanks` : `${SITE}/contact?error=send`, 303);
  },
};
  • Siteverify accepte des corps JSON ou form-encoded et répond toujours en JSON. remoteip est facultatif ; idempotency_key permet de relancer une vérification sans risque après une erreur réseau.
  • Vérifiez hostname et action, pas seulement success. Un token émis sur la page de quelqu'un d'autre avec votre site key indique aussi success.
  • Stockez les deux secrets avec wrangler secret put, jamais dans wrangler.toml.
  • Le Worker répond par une redirection 303 : le formulaire simple n'a donc besoin d'aucun JavaScript. Faites pointer l'action du formulaire vers la route du Worker.
  • Il transmet l'en-tête Origin de la page, car un fetch côté serveur n'en envoie aucun et Ikiform le lit pour reconnaître les tests gratuits depuis localhost.

Pour afficher l'erreur sur la page du formulaire après un échec de vérification, lisez la query string que définit le Worker :

contact.html
<p id="form-error" role="alert" hidden></p>
<script>
  const reason = new URLSearchParams(location.search).get("error");
  const banner = document.getElementById("form-error");
  if (reason && banner) {
    banner.textContent =
      reason === "check" ? "The human check didn't pass. Try again." : "Your message didn't send. Try again.";
    banner.hidden = false;
  }
</script>
Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore Allowed domains et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la limite de débit Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.

Laisser Ikiform le vérifier

Si vous ne voulez faire tourner aucun serveur, le formulaire peut envoyer directement à Ikiform, qui fait l'appel à siteverify. Le réglage Spam check de chaque formulaire décide comment.

CheminClés que vous gérezWidget sur votre pageServeur à vous
Votre propre serveur ou Worker, ci-dessusSite key et clé secrèteOui, le vôtreOui
Ikiform avec Your own Turnstile keysSite key et clé secrète, collées dans IkiformOui, le vôtreNon
Ikiform avec IkiformAucuneNon. Une page de vérification sur ikiform.com, ou un petit cadreNon

Avec Your own Turnstile keys, collez la site key et la clé secrète dans les réglages du formulaire. Le secret est chiffré au repos. Gardez le même balisage du widget, mais faites pointer le formulaire vers votre endpoint Ikiform. Ikiform vérifie cf-turnstile-response et compare le hostname du token aux Allowed domains du formulaire. Un envoi sans token valide reçoit 403 captcha_failed, même avec une clé API secrète.

contact.html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />
  <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>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
  <button>Send message</button>
</form>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

Avec Ikiform, vous n'ajoutez rien à la page. Un envoi HTML simple sans token reçoit une courte page Checking you're human sur ikiform.com qui exécute Turnstile et transmet les champs. Un envoi fetch reçoit plutôt 403 challenge_required ; appelez d'abord challenge() de @ikiform/sdk pour obtenir un token. Aucun compte Cloudflare n'entre en jeu. La documentation sur le spam montre les deux parcours.

Si Cloudflare est injoignable, Ikiform ne jette pas l'envoi : il le stocke comme spam avec la raison captcha_unavailable, pour que vous puissiez le restaurer depuis le tableau de bord. Votre propre serveur doit faire le même choix délibérément, soit en échouant de manière fermée comme le Worker ci-dessus, soit en gardant l'envoi pour relecture.

Clés de test

Cloudflare publie des clés factices qui fonctionnent sur n'importe quel hostname, y compris localhost. Utilisez-les pendant le développement, puis remplacez-les par vos vraies clés.

CléValeurComportement
Site key1x00000000000000000000AAPasse toujours, widget visible
Clé secrète1x0000000000000000000000000000000AASiteverify répond toujours success
Clé secrète2x0000000000000000000000000000000AASiteverify répond toujours un échec

Les tokens de test ne portent pas votre vrai hostname : la vérification du hostname du Worker les refuse donc. N'assouplissez cette vérification que dans votre environnement de développement, jamais en production.

Vérifiez que tout fonctionne

  1. Envoyez le formulaire normalement. L'envoi est accepté et le widget n'a rien affiché, ou une simple case à cocher.
  2. Retirez l'élément cf-turnstile et envoyez. L'envoi est refusé : le Worker redirige avec ?error=check, ou Ikiform répond 403 captcha_failed.
  3. Envoyez, revenez en arrière et renvoyez la même page. Le token réutilisé échoue, ce qui montre que le serveur le vérifie vraiment.
  4. Ouvrez Submissions dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
  5. 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

  • Un token, un essai. Après un envoi échoué géré avec fetch, appelez turnstile.reset() pour obtenir un nouveau token. Un rechargement complet de la page en obtient un automatiquement.
  • Les personnes qui tapent lentement. Les tokens expirent après 300 secondes. Le réglage data-refresh-expired du widget décide s'il en récupère un nouveau tout seul ou s'il attend un clic.
  • www et le domaine nu. www.example.com et example.com sont deux hostnames différents pour siteverify. Comparez avec celui où tourne réellement votre formulaire, ou autorisez les deux.
  • Navigation monopage. Le script implicite rend les widgets une seule fois, à son chargement. Lors d'un changement de route côté client, faites un rendu explicite avec ?render=explicit et turnstile.render().
  • Envois serveur avec les clés d'Ikiform. Si votre serveur transmet à Ikiform avec une clé secrète, Ikiform saute sa vérification gérée. Vérifiez d'abord le token vous-même, comme le fait le Worker.

Questions

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.