# Comment arrêter le spam sur un formulaire de contact

> Arrêtez le spam des formulaires de contact avec des couches invisibles pour les vrais visiteurs : honeypot, délai, score du contenu, limites de débit, vérification humaine si besoin.

Publié le 2026-10-25 · https://ikiform.com/fr/guides/spam-protection

Empilez des vérifications peu coûteuses et invisibles avant d'ajouter la moindre friction : un champ honeypot masqué, un délai minimum pour remplir le formulaire, un score pour les liens et les formules de spam, et une limite de débit par visiteur. N'ajoutez une vérification humaine comme Cloudflare Turnstile que si du spam passe encore, et vérifiez son token sur un serveur. Conservez ce que vous attrapez au lieu de le jeter, pour pouvoir repêcher le rare vrai message.

Un formulaire de contact sur une page publique commence à recevoir des déchets quelques jours après son indexation. La plupart ne vous visent pas. Des scripts parcourent le web à la recherche du moindre `<form>`, remplissent chaque input et l'envoient, en espérant qu'un lien atterrisse quelque part. Une part plus petite vient de vrais navigateurs pilotés par un logiciel, et une partie de personnes payées pour coller le même argumentaire dans des milliers de formulaires. Chaque type s'arrête avec une vérification différente : c'est pourquoi une seule astuce ne règle jamais le problème.

Ce guide explique chaque défense, ce qu'elle attrape et ce qu'elle coûte aux vrais visiteurs, avec du code à exécuter sur n'importe quelle stack. Il montre ensuite comment Ikiform, un backend de formulaires, applique ces mêmes couches pour vous.

## Des couches, de la moins coûteuse à la plus coûteuse

Classez les vérifications selon leur coût. Compter les requêtes ne coûte presque rien : cela passe donc avant toute lecture du corps. Les vérifications qui appellent un autre service, comme un captcha, s'exécutent une fois les moins coûteuses passées. Le score du contenu vient en dernier, car il a besoin des champs analysés. Ikiform exécute ses vérifications dans cet ordre sur chaque envoi.

Schéma : L'ordre des vérifications anti-spam, de l'arrivée d'un envoi à son stockage ou au spam

1. Un envoi arrive sur l'endpoint du formulaire.
2. La limite de débit compte d'abord les envois par visiteur. Un flot est refusé avec 429 avant la lecture du corps.
3. Les domaines autorisés comparent la page d'où vient l'envoi avec les domaines que vous avez listés.
4. Si la vérification humaine est activée, l'envoi doit porter un token Turnstile valide.
5. Le honeypot ajoute 5 au score lorsqu'il est rempli.
6. Le délai, les liens, les formules de spam connues, les adresses jetables et les répétitions ajoutent de plus petites valeurs.
7. À 5 ou plus, l'envoi est stocké comme spam. En dessous, l'envoi est reçu et vos e-mails et webhooks partent.

La ligne du haut refuse les envois. La ligne du bas ne refuse jamais : elle attribue un score, donc un faux positif se restaure d'un clic, sans message perdu.

## Les défenses comparées

Chaque défense a un prix. Le tableau dit franchement ce que chacune laisse passer et ce qu'elle demande aux personnes dont vous voulez avoir des nouvelles.

| Défense | Arrête | Laisse passer | Coût pour les vrais visiteurs |
| --- | --- | --- | --- |
| [Honeypot](https://ikiform.com/fr/guides/spam-protection/honeypot.md) | Les scripts qui remplissent chaque input | Les bots qui lisent le CSS ou ignorent les champs masqués, et les humains | Aucun, s'il est masqué aux lecteurs d'écran et au remplissage automatique |
| Piège temporel | Les envois faits plus vite que quiconque ne peut taper | Les bots lents, et ceux qui attendent | Aucun, tant que le minimum est de quelques secondes |
| Score du contenu, comme le nombre de liens, les formules de spam ou Akismet | Les démarchages SEO, dépôts de liens, spam de casino et de médicaments | Le spam d'apparence propre, et il peut signaler un vrai message contenant des liens | Aucun, si vous relisez ce qu'il attrape |
| [Rate limiting](https://ikiform.com/fr/guides/spam-protection/rate-limiting.md) | Les flots et les scripts en boucle | Le spam lent et étalé dans le temps | Des bureaux ou des événements derrière une seule IP peuvent atteindre une limite serrée |
| Domaines autorisés | Les envois faits directement vers votre endpoint depuis ailleurs | Les scripts qui falsifient l'en-tête `Origin` | Aucune |
| [Cloudflare Turnstile](https://ikiform.com/fr/guides/spam-protection/cloudflare-turnstile.md) | La plupart des navigateurs automatisés | Les spammeurs humains rémunérés | Généralement invisible. Parfois un clic, et il faut un script |
| [reCAPTCHA ou hCaptcha](https://ikiform.com/fr/guides/spam-protection/recaptcha-alternatives.md) | La plupart des navigateurs automatisés | Les spammeurs humains rémunérés et les services de résolution | Des puzzles d'images pour certains visiteurs, plus un script tiers |

Commencez par les cinq premières. Elles sont invisibles et attrapent l'essentiel de ce que reçoit un petit site. Ajoutez une vérification humaine quand le tableau de bord vous montre que du spam passe encore, pas avant : c'est la seule couche dont chaque visiteur peut sentir le coût. La page sur [arrêter le spam sans captcha](https://ikiform.com/fr/guides/spam-protection/without-captcha.md) montre jusqu'où vont les couches invisibles à elles seules.

## Lire le spam avant de le combattre

Regardez dix messages de spam avant de choisir une solution. Ce qu'ils ont en commun vous dit qui les envoie, et cela indique la couche qui les arrête.

Schéma : De l'apparence du spam à la couche qui l'arrête

1. Du texte aléatoire dans chaque champ vient d'un script qui remplit tous les inputs. Un honeypot l'attrape.
2. Des dizaines d'envois par minute depuis une même source viennent d'un script en boucle. Une limite de débit l'arrête.
3. Des envois soignés faits moins d'une seconde après le chargement de la page viennent d'un navigateur headless. Un piège temporel ou une vérification humaine l'arrête.
4. Des démarchages polis avec des liens viennent de personnes payées au formulaire. Seuls le score du contenu et votre propre relecture les attrapent.

Aucun captcha n'arrête la dernière ligne. Une personne peut le résoudre : le score du contenu et un coup d'œil hebdomadaire au dossier de spam sont donc la seule défense.

## Un formulaire de contact avec les couches en place

Voici un formulaire de contact, construit pour chaque stack. Chaque version a le honeypot hors écran et une vérification humaine. Les versions 100 % navigateur utilisent la vérification Turnstile gérée par Ikiform, qui ne demande aucune clé. Les versions serveur montrent la voie à faire soi-même : votre serveur vérifie un token Turnstile avec vos propres clés Cloudflare, puis transmet les champs. Choisissez votre stack ci-dessous ; le choix s'applique à tous les exemples de code de ces guides.

Les onglets serveur partagent deux petits helpers. `verifyTurnstile` demande à Cloudflare si un token est authentique, et `sendToIkiform` transmet les champs avec votre clé secrète et l'en-tête `Origin` du visiteur. Un fetch côté serveur n'envoie pas d'`Origin` propre, et Ikiform le lit pour reconnaître un test gratuit depuis `localhost`. Les deux échouent de manière fermée : si Cloudflare est injoignable, le visiteur voit une erreur plutôt qu'un passage silencieux.

`lib/spam.ts`:

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

export interface Contact {
  name: string;
  email: string;
  message: string;
  _gotcha: string;
}

export function readContact(data: FormData): Contact {
  return {
    name: String(data.get("name") ?? "").trim(),
    email: String(data.get("email") ?? "").trim(),
    message: String(data.get("message") ?? "").trim(),
    _gotcha: String(data.get("_gotcha") ?? ""),
  };
}

export async function verifyTurnstile(input: { secret: string; token: string; ip?: string | null }) {
  if (!input.token || input.token.length > 2048) return false;
  const body = new URLSearchParams({ secret: input.secret, response: input.token });
  if (input.ip) body.set("remoteip", input.ip);
  try {
    const response = await fetch(SITEVERIFY, { method: "POST", body });
    const result = (await response.json()) as { success?: boolean };
    return result.success === true;
  } catch {
    return false;
  }
}

export async function sendToIkiform(secretKey: string, contact: Contact, origin?: string | null) {
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${secretKey}`,
        ...(origin ? { Origin: origin } : {}),
      },
      body: JSON.stringify(contact),
    });
    const result = await response.json().catch(() => null);
    if (result?.ok) return { ok: true as const };
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const first = Object.values(fields)[0]?.[0];
    return {
      ok: false as const,
      status: response.status,
      message: String(first ?? result?.error?.message ?? "Couldn't send your message. Try again."),
    };
  } catch {
    return { ok: false as const, status: 502, message: "Couldn't reach the form service. Try again." };
  }
}
```

**HTML**

Le formulaire simple ne demande aucun script. Réglez le **Spam check** du formulaire Ikiform sur **Ikiform**, et un envoi sans token reçoit une courte page de vérification sur ikiform.com qui exécute Turnstile et transmet les champs. Les personnes qui la passent de façon invisible ne s'en aperçoivent jamais.

`contact.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required maxlength="120" />

  <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 minlength="10" maxlength="5000"></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 message</button>
</form>

<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>
```

Le honeypot est placé hors écran plutôt que derrière `display: none`, car certains bots ignorent les champs masqués de cette façon. L'`aria-hidden` du conteneur et le `tabindex` de l'input tiennent les lecteurs d'écran et le clavier à l'écart.

**Next.js**

Une server action vérifie le token Turnstile avec vos propres clés, puis transmet les champs avec votre clé secrète Ikiform. Le honeypot suit tel quel : un honeypot rempli est donc stocké comme spam, où vous pouvez le relire. Enregistrez les helpers ci-dessus dans `lib/spam.ts`. Définissez `TURNSTILE_SECRET_KEY` et `IKIFORM_SECRET_KEY` comme variables d'environnement côté serveur uniquement, et laissez le **Spam check** du formulaire Ikiform sur **Off** : votre serveur a déjà fait la vérification humaine.

`app/contact/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { readContact, sendToIkiform, verifyTurnstile } from "@/lib/spam";

export interface ContactState {
  ok: boolean;
  message: string;
  values?: { name: string; email: string; message: string };
}

export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
  const contact = readContact(formData);
  const values = { name: contact.name, email: contact.email, message: contact.message };
  const incoming = await headers();
  const forwarded = incoming.get("x-forwarded-for");

  const human = await verifyTurnstile({
    secret: process.env.TURNSTILE_SECRET_KEY!,
    token: String(formData.get("cf-turnstile-response") ?? ""),
    ip: forwarded?.split(",")[0]?.trim(),
  });
  if (!human) return { ok: false, message: "We couldn't confirm you're human. Try the check again.", values };

  const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, contact, incoming.get("origin"));
  if (result.ok) return { ok: true, message: "Thanks. We'll reply by email." };
  return { ok: false, message: result.message, values };
}
```

`app/contact/contact-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { Turnstile } from "@/components/turnstile";
import { type ContactState, sendMessage } from "./actions";

const initial: ContactState = { ok: false, message: "" };

export function ContactForm() {
  const [state, action, pending] = useActionState(sendMessage, initial);

  if (state.ok) return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} defaultValue={state.values?.name} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email} />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} defaultValue={state.values?.message} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} reset={state} />
      {state.message ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

`components/turnstile.tsx`:

```tsx
"use client";

import { useEffect, useRef } from "react";

interface TurnstileApi {
  render(container: HTMLElement, options: { sitekey: string; action?: string }): string;
  remove(widgetId: string): void;
}

declare global {
  interface Window {
    turnstile?: TurnstileApi;
  }
}

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

function loadTurnstile() {
  return new Promise<TurnstileApi>((resolve) => {
    if (window.turnstile) return resolve(window.turnstile);
    let script = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT}"]`);
    if (!script) {
      script = document.createElement("script");
      script.src = SCRIPT;
      script.async = true;
      document.head.append(script);
    }
    script.addEventListener("load", () => window.turnstile && resolve(window.turnstile), { once: true });
  });
}

export function Turnstile({ siteKey, reset }: { siteKey: string; reset?: unknown }) {
  const box = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let widgetId: string | undefined;
    let active = true;
    loadTurnstile().then((turnstile) => {
      if (active && box.current) widgetId = turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
    });
    return () => {
      active = false;
      if (widgetId) window.turnstile?.remove(widgetId);
    };
  }, [siteKey, reset]);

  return <div ref={box} />;
}
```

Le widget est rendu explicitement et rendu à nouveau à chaque retour de l'action, car un token Turnstile ne sert qu'une fois. `defaultValue` remet le texte du visiteur après une erreur, puisque React réinitialise un formulaire après l'exécution de son action.

Pour aller plus loin avec Next.js, lisez [Protection anti-spam d'un formulaire Next.js avec Turnstile](https://ikiform.com/fr/guides/spam-protection/nextjs.md).

**Astro**

Une Astro Action valide les champs avec Zod, vérifie le token avec `context.clientAddress` et transmet l'envoi. Enregistrez les helpers ci-dessus dans `src/lib/spam.ts` et lisez les deux clés avec `getSecret` depuis `astro:env/server`. Le formulaire envoie sans JavaScript côté client : chaque tentative recharge donc la page et le widget est rendu à neuf. Ajoutez un adaptateur et gardez `prerender = false`. Définissez `TURNSTILE_SECRET_KEY` et `IKIFORM_SECRET_KEY` comme variables d'environnement côté serveur uniquement, et laissez le **Spam check** du formulaire Ikiform sur **Off** : votre serveur a déjà fait la vérification humaine.

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
import { sendToIkiform, verifyTurnstile } from "../lib/spam";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().trim().min(1).max(120),
      email: z.email(),
      message: z.string().trim().min(10).max(5000),
      _gotcha: z.string().nullish(),
      "cf-turnstile-response": z.string().nullish(),
    }),
    handler: async (input, context) => {
      const human = await verifyTurnstile({
        secret: getSecret("TURNSTILE_SECRET_KEY") ?? "",
        token: input["cf-turnstile-response"] ?? "",
        ip: context.clientAddress,
      });
      if (!human) throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try the check again." });

      const result = await sendToIkiform(getSecret("IKIFORM_SECRET_KEY") ?? "", {
        name: input.name,
        email: input.email,
        message: input.message,
        _gotcha: input._gotcha ?? "",
      }, context.request.headers.get("origin"));
      if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.message });
      return { sent: true };
    },
  }),
};
```

`src/pages/contact.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";

export const prerender = false;

const result = Astro.getActionResult(actions.sendMessage);
const siteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY;
---

{result?.data?.sent ? (
  <p role="status">Thanks. We'll reply by email.</p>
) : (
  <form method="POST" action={actions.sendMessage}>
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" />
    <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 minlength="10"></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>
    <div class="cf-turnstile" data-sitekey={siteKey} data-action="contact"></div>
    {result?.error && (
      <p role="alert">{isInputError(result.error) ? "Check your name, email and message." : result.error.message}</p>
    )}
    <button>Send message</button>
  </form>
)}

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

Pour aller plus loin avec Astro, lisez [Protection anti-spam d'un formulaire Astro avec Turnstile](https://ikiform.com/fr/guides/spam-protection/astro.md).

**TanStack Start**

Une fonction POST `createServerFn` lit l'IP du visiteur avec `getRequestHeader`, vérifie le token et transmet les champs. Le composant du widget est le même que dans l'onglet Next.js, dans `src/components/turnstile.tsx`. Définissez `TURNSTILE_SECRET_KEY` et `IKIFORM_SECRET_KEY` comme variables d'environnement côté serveur uniquement, et laissez le **Spam check** du formulaire Ikiform sur **Off** : votre serveur a déjà fait la vérification humaine.

`src/routes/contact.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
import { Turnstile } from "../components/turnstile";
import { readContact, sendToIkiform, verifyTurnstile } from "../lib/spam";

const sendMessage = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return { contact: readContact(data), token: String(data.get("cf-turnstile-response") ?? "") };
  })
  .handler(async ({ data }) => {
    const human = await verifyTurnstile({
      secret: process.env.TURNSTILE_SECRET_KEY!,
      token: data.token,
      ip: getRequestHeader("x-forwarded-for")?.split(",")[0]?.trim(),
    });
    if (!human) return { ok: false, message: "We couldn't confirm you're human. Try the check again." };
    const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, data.contact, getRequestHeader("origin"));
    return result.ok ? { ok: true, message: "Thanks. We'll reply by email." } : { ok: false, message: result.message };
  });

export const Route = createFileRoute("/contact")({ component: Contact });

function Contact() {
  const send = useServerFn(sendMessage);
  const [state, setState] = useState<{ ok: boolean; message: string } | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setPending(true);
    try {
      setState(await send({ data }));
    } catch {
      setState({ ok: false, message: "Couldn't send your message. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (state?.ok) return <p role="status">{state.message}</p>;

  return (
    <form method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} reset={state} />
      {state?.message ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

Pour aller plus loin avec TanStack Start, lisez [Protection anti-spam d'un formulaire TanStack Start avec Turnstile](https://ikiform.com/fr/guides/spam-protection/tanstack-start.md).

**React Router**

L'`action` de la route vérifie le token et transmet l'envoi. Passer `actionData` comme `reset` affiche un nouveau widget après chaque réponse. Le composant du widget est celui de l'onglet Next.js. Définissez `TURNSTILE_SECRET_KEY` et `IKIFORM_SECRET_KEY` comme variables d'environnement côté serveur uniquement, et laissez le **Spam check** du formulaire Ikiform sur **Off** : votre serveur a déjà fait la vérification humaine.

`app/routes/contact.tsx`:

```tsx
import { data, Form, useNavigation } from "react-router";
import { Turnstile } from "../components/turnstile";
import { readContact, sendToIkiform, verifyTurnstile } from "../lib/spam";
import type { Route } from "./+types/contact";

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const human = await verifyTurnstile({
    secret: process.env.TURNSTILE_SECRET_KEY!,
    token: String(formData.get("cf-turnstile-response") ?? ""),
    ip: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
  });
  if (!human) return data({ ok: false, message: "We couldn't confirm you're human. Try the check again." }, { status: 403 });

  const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, readContact(formData), request.headers.get("origin"));
  if (result.ok) return { ok: true, message: "Thanks. We'll reply by email." };
  return data({ ok: false, message: result.message }, { status: result.status });
}

export default function Contact({ actionData }: Route.ComponentProps) {
  const navigation = useNavigation();
  const pending = navigation.state === "submitting";

  if (actionData?.ok) return <p role="status">{actionData.message}</p>;

  return (
    <Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} reset={actionData} />
      {actionData?.message ? <p role="alert">{actionData.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </Form>
  );
}
```

Pour aller plus loin avec React Router, lisez [Protection anti-spam d'un formulaire React Router avec Turnstile](https://ikiform.com/fr/guides/spam-protection/react-router.md).

**SvelteKit**

Une form action par défaut vérifie le token avec `getClientAddress()` et transmet l'envoi. Cette version omet volontairement `use:enhance` : chaque tentative recharge la page, et Turnstile affiche donc un nouveau widget avec un nouveau token. Enregistrez les helpers ci-dessus dans `src/lib/spam.ts`. Déclarez les clés dans `src/env.ts` avec `defineEnvVars` et donnez à `PUBLIC_TURNSTILE_SITE_KEY` `public: true`, pour que la page puisse l'importer depuis `$app/env/public`. Définissez `TURNSTILE_SECRET_KEY` et `IKIFORM_SECRET_KEY` comme variables d'environnement côté serveur uniquement, et laissez le **Spam check** du formulaire Ikiform sur **Off** : votre serveur a déjà fait la vérification humaine.

`src/routes/contact/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY, TURNSTILE_SECRET_KEY } from "$app/env/private";
import { readContact, sendToIkiform, verifyTurnstile } from "#lib/spam.js";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, getClientAddress, url }) => {
    const data = await request.formData();
    const contact = readContact(data);
    const values = { name: contact.name, email: contact.email, message: contact.message };

    const human = await verifyTurnstile({
      secret: TURNSTILE_SECRET_KEY,
      token: String(data.get("cf-turnstile-response") ?? ""),
      ip: getClientAddress(),
    });
    if (!human) return fail(403, { values, error: "We couldn't confirm you're human. Try the check again." });

    const result = await sendToIkiform(IKIFORM_SECRET_KEY, contact, request.headers.get("origin") ?? url.origin);
    if (!result.ok) return fail(result.status, { values, error: result.message });
    return { sent: true };
  },
} satisfies Actions;
```

`src/routes/contact/+page.svelte`:

```svelte
<script lang="ts">
  import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
</script>

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

{#if form?.sent}
  <p role="status">Thanks. We'll reply by email.</p>
{:else}
  <form method="POST">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.values?.name ?? ""} />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required minlength="10" value={form?.values?.message ?? ""}></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>
    <div class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
    {#if form?.error}
      <p role="alert">{form.error}</p>
    {/if}
    <button>Send message</button>
  </form>
{/if}
```

Pour aller plus loin avec SvelteKit, lisez [Protection anti-spam d'un formulaire SvelteKit avec Turnstile](https://ikiform.com/fr/guides/spam-protection/sveltekit.md).

**Nuxt**

Une server route vérifie le token et transmet l'envoi. Enregistrez les helpers ci-dessus dans `server/utils/spam.ts` et Nuxt les importe automatiquement. Le composant rend Turnstile explicitement et le réinitialise après une tentative échouée. Définissez `TURNSTILE_SECRET_KEY` et `IKIFORM_SECRET_KEY` comme variables d'environnement côté serveur uniquement, et laissez le **Spam check** du formulaire Ikiform sur **Off** : votre serveur a déjà fait la vérification humaine.

`server/api/contact.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const body = await readBody<Record<string, string | undefined>>(event);
  const config = useRuntimeConfig(event);

  const human = await verifyTurnstile({
    secret: config.turnstileSecretKey,
    token: body["cf-turnstile-response"] ?? "",
    ip: getRequestIP(event, { xForwardedFor: true }),
  });
  if (!human) throw createError({ status: 403, message: "We couldn't confirm you're human. Try the check again." });

  const result = await sendToIkiform(config.ikiformSecretKey, {
    name: body.name?.trim() ?? "",
    email: body.email?.trim() ?? "",
    message: body.message?.trim() ?? "",
    _gotcha: body._gotcha ?? "",
  }, getRequestHeader(event, "origin"));
  if (!result.ok) throw createError({ status: result.status, message: result.message });
  return { sent: true };
});
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
    turnstileSecretKey: "",
    public: {
      turnstileSiteKey: "",
    },
  },
});
```

`app/components/ContactForm.vue`:

```vue
<script setup lang="ts">
interface TurnstileApi {
  render(
    container: HTMLElement,
    options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
  ): string;
  reset(widgetId: string): void;
}

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
const config = useRuntimeConfig();
const fields = reactive({ name: "", email: "", message: "", _gotcha: "" });
const box = ref<HTMLDivElement>();
const token = ref("");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
let widgetId: string | undefined;

function api() {
  return (window as Window & { turnstile?: TurnstileApi }).turnstile;
}

function loadTurnstile() {
  return new Promise<TurnstileApi>((resolve) => {
    const ready = api();
    if (ready) return resolve(ready);
    let script = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT}"]`);
    if (!script) {
      script = document.createElement("script");
      script.src = SCRIPT;
      script.async = true;
      document.head.append(script);
    }
    script.addEventListener("load", () => api() && resolve(api()!), { once: true });
  });
}

onMounted(async () => {
  const turnstile = await loadTurnstile();
  if (!box.value) return;
  widgetId = turnstile.render(box.value, {
    sitekey: config.public.turnstileSiteKey,
    action: "contact",
    callback: (value) => (token.value = value),
    "expired-callback": () => (token.value = ""),
  });
});

async function send() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/contact", { method: "POST", body: { ...fields, "cf-turnstile-response": token.value } });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send your message. Try again.";
    status.value = "idle";
    token.value = "";
    if (widgetId) api()?.reset(widgetId);
  }
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="fields.name" name="name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" v-model="fields.email" name="email" type="email" autocomplete="email" required />
    <label for="message">Message</label>
    <textarea id="message" v-model="fields.message" name="message" rows="5" required minlength="10" />
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" v-model="fields._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div ref="box" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending' || !token">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

Pour aller plus loin avec Nuxt, lisez [Protection anti-spam d'un formulaire Nuxt avec Turnstile](https://ikiform.com/fr/guides/spam-protection/nuxt.md).

**React**

Une application monopage n'a pas de serveur où garder un secret Turnstile : elle utilise donc la vérification gérée par Ikiform. `challenge()` de `@ikiform/sdk` monte un petit cadre ikiform.com qui reste invisible sauf si Turnstile demande un clic, et renvoie un token pour `submit()`. Réglez **Spam check** sur **Ikiform** et ajoutez votre domaine sous **Allowed domains**.

`src/ContactForm.tsx`:

```tsx
import { challenge, submit } from "@ikiform/sdk";
import { type FormEvent, useRef, useState } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

export function ContactForm() {
  const check = useRef<HTMLDivElement>(null);
  const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
  const [error, setError] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setStatus("sending");
    setError("");
    try {
      const turnstileToken = await challenge(FORM_ID, { container: check.current ?? undefined });
      const result = await submit(FORM_ID, data, { turnstileToken });
      if (result.ok) {
        setStatus("sent");
        return;
      }
      setError(Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message);
    } catch {
      setError("The human check didn't finish. Try again.");
    }
    setStatus("idle");
  }

  if (status === "sent") return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <div ref={check} />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

**Vue**

La même vérification gérée dans un composant monofichier Vue. Le cadre n'apparaît dans le `div` vide que lorsqu'un visiteur doit cliquer.

`src/components/ContactForm.vue`:

```vue
<script setup lang="ts">
import { challenge, submit } from "@ikiform/sdk";
import { ref } from "vue";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

const check = ref<HTMLDivElement>();
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");

async function send(event: SubmitEvent) {
  const data = new FormData(event.target as HTMLFormElement);
  status.value = "sending";
  error.value = "";
  try {
    const turnstileToken = await challenge(FORM_ID, { container: check.value });
    const result = await submit(FORM_ID, data, { turnstileToken });
    if (result.ok) {
      status.value = "sent";
      return;
    }
    error.value = Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message;
  } catch {
    error.value = "The human check didn't finish. Try again.";
  }
  status.value = "idle";
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" />
    <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 minlength="10" />
    <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>
    <div ref="check" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

- Chaque version utilise la classe `.hp` du bloc de style de l'onglet HTML. Placez-la dans votre CSS global.
- Le honeypot s'appelle `_gotcha`, le nom par défaut d'Ikiform. Les versions serveur le transmettent tel quel : un honeypot rempli atterrit donc quand même dans votre dossier de spam avec la raison `honeypot`.
- Turnstile ajoute au formulaire un input masqué `cf-turnstile-response`. Un token est valable 300 secondes et ne sert qu'une fois : chaque tentative en demande donc un nouveau.
- Les clés de test de Cloudflare vous permettent de tout exécuter en local. Le [guide Turnstile](https://ikiform.com/fr/guides/spam-protection/cloudflare-turnstile.md) les liste.

> 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](https://ikiform.com/fr/docs/rate-limits.md) 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.

Essayez la définition de champs ci-dessous. Elle exécute le validateur d'Ikiform dans votre navigateur, avec les mêmes règles que celles appliquées par le serveur avant tout calcul de score de spam.

Formulaire : Contact

- **Nom** (`name`, text, obligatoire)
- **E-mail** (`email`, email, obligatoire)
- **Sujet** (`topic`, select)
- **Message** (`message`, textarea, obligatoire)

Les mêmes champs que le [modèle de formulaire de contact](https://ikiform.com/forms/contact-form). Collez la définition dans l'onglet **Fields** du formulaire et Ikiform refuse les envois qui ne la respectent pas avec `422 validation_failed`.

## Le honeypot

Un honeypot est un input que les personnes ne voient jamais et que les bots remplissent. S'il a une valeur, l'envoi vient d'un logiciel. Il ne coûte rien aux visiteurs et attrape les bots les plus anciens et les plus courants, ceux qui analysent le HTML et remplissent chaque input trouvé.

Bien le masquer compte plus que l'astuce elle-même. Trois choses tournent mal avec un honeypot fait à la légère :

- **Les lecteurs d'écran l'annoncent.** Un visiteur aveugle entend un champ de plus, le remplit, et se retrouve marqué comme spam. Englobez-le dans `aria-hidden="true"` et donnez-lui un label qui demande de le laisser vide.
- **Le clavier s'y arrête.** Parcourir le formulaire avec Tab s'arrête sur un input invisible. `tabindex="-1"` le retire de l'ordre de tabulation.
- **Le remplissage automatique le remplit.** Les navigateurs et les gestionnaires de mots de passe remplissent les champs selon leur nom. Appelez le champ `email2` ou `phone` et certains le rempliront pour de vraies personnes. Utilisez un nom qu'aucun remplissage automatique ne reconnaît, comme `_gotcha`, et ajoutez `autocomplete="off"`.

Le [guide du honeypot](https://ikiform.com/fr/guides/spam-protection/honeypot.md) couvre les techniques pour le masquer, la façon dont les bots les repèrent, et la vérification côté serveur pour chaque stack. Avec Ikiform, un honeypot rempli ajoute 5 au score, ce qui atteint à lui seul le seuil de 5. Vous pouvez renommer le champ pour chaque formulaire sous **Honeypot field**.

## Le délai

Une personne met des secondes à remplir un formulaire de contact. Beaucoup de bots mettent des millisecondes. Un piège temporel enregistre le moment où le formulaire a été rendu et vérifie l'écart à l'envoi. La version naïve place un horodatage dans un champ masqué :

`Piège temporel, la version naïve`:

```ts
const started = Number(formData.get("started"));
const seconds = (Date.now() - started) / 1000;
const tooFast = !Number.isFinite(seconds) || seconds < 3;
```

Un bot peut envoyer le nombre qu'il veut : un simple horodatage n'arrête donc que les bots qui ne s'en donnent pas la peine. Signez-le sur le serveur avec un secret, et le bot ne peut plus simuler un rendu plus ancien. La page sur [arrêter le spam sans captcha](https://ikiform.com/fr/guides/spam-protection/without-captcha.md#signed-timestamp) construit la version signée.

Ikiform signe les heures de rendu pour vous quand le formulaire vient du SDK ou du [composant shadcn/ui](https://ikiform.com/fr/docs/shadcn.md) : ils envoient `_ikiform_ts`, et un envoi fait moins de 2 secondes après le rendu ajoute 3 au score. Un horodatage falsifié ou vieux d'un jour ajoute 3. Les formulaires HTML simples n'en envoient pas et ne sont pas pénalisés.

## Le score du contenu

Une partie du spam est tapée ou collée par des personnes : elle passe donc toutes les vérifications anti-bot. Ce qui la trahit, c'est le contenu : liens, formules commerciales, adresses jetables et le même texte envoyé deux fois. Attribuez un score à chaque signal et traitez un total élevé comme du spam, plutôt que de bloquer sur un seul. Un lien dans un message est normal ; un lien dans le champ du nom ne l'est jamais.

| Signal | Ikiform ajoute |
| --- | --- |
| Le honeypot est rempli | +5 |
| Un lien dans un champ de nom ou d'objet | +3 |
| Les mêmes champs envoyés à ce formulaire dans les dernières 24 heures | +3 |
| Envoyé moins de 2 secondes après l'heure de rendu signée | +3 |
| Trois liens ou plus sur l'ensemble des champs | +2 |
| L'une des 22 formules de spam connues, comme « seo services » | +2 |
| Une adresse sur un domaine d'e-mail jetable | +2 |

À 5 ou plus, l'envoi est du spam. Aucun signal faible n'y arrive seul : un vrai message avec trois liens obtient 2. Deux ensemble y arrivent souvent, comme un lien dans le nom et une formule de spam. Si vous notez le contenu vous-même, Akismet est l'option hébergée de longue date ; beaucoup d'équipes commencent par quelques expressions régulières comme celles ci-dessus.

## Une vérification humaine

Une vérification humaine demande au navigateur de prouver qu'une personne le pilote. Les modernes, comme Cloudflare Turnstile, tournent surtout de façon invisible et n'affichent une case à cocher qu'en cas de doute. Le widget donne un token au navigateur ; votre serveur doit envoyer ce token au fournisseur pour le vérifier. Un token jamais vérifié sur un serveur n'arrête rien, car un bot peut ignorer le widget et envoyer sans lui.

Ikiform propose trois réglages sous **Spam check** sur chaque formulaire :

| Réglage | Ce qui se passe | Changements de code |
| --- | --- | --- |
| **Off** (the default) | Aucun défi. Le honeypot, le délai et le score s'exécutent toujours. | Aucune |
| **Ikiform** | Turnstile exécuté par Ikiform sur ikiform.com, sans compte ni clés Cloudflare | Aucun pour les envois HTML. Les envois `fetch` appellent d'abord `challenge()` de `@ikiform/sdk` |
| **Your own Turnstile keys** (vos propres clés Turnstile) | Votre widget et votre clé secrète. Ikiform vérifie le token et compare son hostname à vos domaines autorisés | Affichez le widget dans votre formulaire pour qu'il envoie `cf-turnstile-response` |

Un envoi `fetch` sans token reçoit `403 challenge_required`. Si Cloudflare est injoignable, Ikiform garde l'envoi et le stocke comme spam avec la raison `captcha_unavailable` : vous pouvez donc le restaurer. La [documentation sur le spam](https://ikiform.com/fr/docs/spam.md#human-check) montre les deux parcours. Si vous hésitez entre Turnstile et reCAPTCHA de Google ou hCaptcha, voyez les [alternatives à reCAPTCHA](https://ikiform.com/fr/guides/spam-protection/recaptcha-alternatives.md).

## Limites de débit et domaines autorisés

Une limite de débit plafonne la fréquence à laquelle un visiteur peut envoyer. Elle ne distingue pas le spam du courrier légitime ; elle arrête le volume. Comptez par IP et par formulaire, autorisez une petite rafale, et répondez `429` avec un en-tête `Retry-After`. Le [guide sur la limitation de débit](https://ikiform.com/fr/guides/spam-protection/rate-limiting.md) en construit une pour chaque stack et explique pourquoi un compteur en mémoire échoue sur les hébergeurs serverless.

Ikiform compte chaque envoi précisément, avant de lire le corps. Chaque formulaire choisit un préréglage : Standard, 5 par minute par visiteur, Relaxed, 20 par minute par visiteur, ou Strict, 2 toutes les 10 minutes par visiteur. Standard autorise aussi au plus 3 envois toutes les 10 secondes, et le formulaire entier en accepte 300 par minute. Strict convient à un formulaire de contact, puisque personne d'honnête n'écrit deux fois en dix minutes. Les chiffres sont sur [Limites de débit](https://ikiform.com/fr/docs/rate-limits.md).

**Allowed domains** liste les sites où vit votre formulaire. Un envoi depuis toute autre page est marqué comme spam, ou refusé avec `403 origin_not_allowed` si vous choisissez **Reject**. Cela empêche des tiers de faire pointer leurs propres formulaires vers votre endpoint. Un script déterminé peut falsifier l'en-tête vérifié : traitez donc cela comme une couche, pas comme un verrou.

## Ce qu'il advient du spam

Ne dites jamais à un bot qu'il a été attrapé. Une erreur lui apprend quoi changer ; une page de succès normale ne lui apprend rien. Et ne supprimez jamais le spam à l'arrivée : tout filtre attrape un vrai message de temps en temps, et un message supprimé est perdu.

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.

Ouvrez le filtre de statut du tableau de bord et choisissez **Spam** pour voir chaque envoi avec son score et ses raisons. **Not spam** le remet en place, et ses e-mails et webhooks partent alors. Désactivez l'e-mail du lundi sous **Weekly spam summary** dans les réglages de l'espace de travail. Le spam est conservé 30 jours : vérifiez donc au moins une fois par mois.

> Si un vrai message se fait attraper à chaque fois, lisez ses raisons. Un lien dans un champ appelé `business_name` compte comme un lien dans un nom ; renommez le champ en `business` et il n'est plus considéré comme un champ de nom.

## Choisissez votre cas

Chacune de ces pages approfondit une défense ou un type de site, avec du code pour ce cas.

- [Champ honeypot](https://ikiform.com/fr/guides/spam-protection/honeypot.md): 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.
- [Cloudflare Turnstile](https://ikiform.com/fr/guides/spam-protection/cloudflare-turnstile.md): 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.
- [Sans captcha](https://ikiform.com/fr/guides/spam-protection/without-captcha.md): Arrêtez l'essentiel du spam sans captcha : un honeypot, une heure de rendu signée, un score du contenu et une limite de débit, cumulés plutôt qu'utilisés seuls.
- [Alternatives à reCAPTCHA](https://ikiform.com/fr/guides/spam-protection/recaptcha-alternatives.md): Comparez les alternatives à reCAPTCHA pour formulaires, de Turnstile et hCaptcha à la preuve de travail ou à l'absence de captcha, puis migrez avec un petit changement sûr.
- [Limitation de débit](https://ikiform.com/fr/guides/spam-protection/rate-limiting.md): Limitez les envois de formulaire par visiteur avec un stockage partagé, répondez 429 avec Retry-After, et évitez de bloquer les bureaux qui partagent une IP.
- [Sites statiques](https://ikiform.com/fr/guides/spam-protection/static-site.md): 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.
- [Spam des formulaires Webflow](https://ikiform.com/fr/guides/spam-protection/webflow.md): Protégez un formulaire Webflow qui envoie vers un backend de formulaires, où les réglages anti-spam de Webflow ne s'appliquent pas, avec un honeypot et une vérification humaine.
- [Next.js](https://ikiform.com/fr/guides/spam-protection/nextjs.md): Protégez un formulaire Next.js du spam : honeypot, Turnstile vérifié dans une server action, limite par IP sur Vercel, Netlify ou Cloudflare, et une CSP adaptée.
- [Astro](https://ikiform.com/fr/guides/spam-protection/astro.md): Protégez un formulaire Astro du spam avec une Action : un honeypot, Turnstile vérifié avec les secrets astro:env, une limite par IP et la CSP intégrée d'Astro.
- [TanStack Start](https://ikiform.com/fr/guides/spam-protection/tanstack-start.md): Protégez un formulaire TanStack Start du spam : un middleware de fonction pour l'IP client, un token bucket, Turnstile vérifié dans une server function, et une CSP.
- [React Router](https://ikiform.com/fr/guides/spam-protection/react-router.md): Protégez un formulaire React Router du spam : une action de route avec Turnstile, une limite Workers basée sur cf-connecting-ip, un module .server et un en-tête CSP.
- [SvelteKit](https://ikiform.com/fr/guides/spam-protection/sveltekit.md): Protégez un formulaire SvelteKit du spam : une form action avec getClientAddress, une limite par IP, Turnstile derrière use:enhance, et une CSP qui l'autorise.
- [Nuxt](https://ikiform.com/fr/guides/spam-protection/nuxt.md): Protégez un formulaire Nuxt du spam : une server route Nitro avec Turnstile, une limite par IP dans le stockage Nitro, des secrets runtimeConfig et une CSP dans routeRules.

## Vérifiez que tout fonctionne

Faites ces tests sur un formulaire de test avant de vous y fier. Ikiform n'a pas d'offre gratuite. Les tests sont gratuits : un espace de travail sans offre accepte 50 soumissions de test par mois depuis `localhost`. Pour passer en production, choisissez Pro à partir de 6 $/mois pour 500 soumissions, ou payez une seule fois : 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais. Voir les [tarifs](https://ikiform.com/fr/pricing).

1. Dans **Allowed domains** du formulaire, ajoutez le domaine de votre site, plus `localhost` pendant le développement. Laissez vide pour accepter les envois de partout.
2. Remplissez le formulaire sur votre page et envoyez-le. Vous arrivez sur votre page de remerciement, ou sur celle hébergée par Ikiform.
3. Ouvrez **Submissions** dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
4. 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.
5. Envoyez un message avec un lien dans le champ du nom et la formule `seo services` dans le message. Il est stocké comme spam avec les raisons `url_in_name` et `keyword`.
6. Réglez **Spam check** sur **Ikiform** et envoyez le formulaire HTML simple. La page de vérification s'affiche un instant, puis votre page de remerciement.
7. Envoyez le formulaire plusieurs fois de suite. Au-delà de la limite, le formulaire répond `429` et le visiteur voit la page de ralentissement d'Ikiform.
8. 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.

## Dépannage

| Problème | Que faire |
| --- | --- |
| La soumission est dans le tableau de bord mais aucun e-mail ni webhook n'est arrivé | Elle a été stockée comme spam. Le spam n'envoie rien. Ouvrez-la sous le filtre **Spam** pour voir les raisons, et marquez-la comme non-spam pour envoyer son e-mail et ses webhooks. |
| `403 challenge_required` | Le spam check du formulaire est réglé sur **Ikiform** et un envoi `fetch` n'avait pas de jeton. Obtenez-en un avec `challenge(formId)` de `@ikiform/sdk` avant chaque envoi, ou désactivez la vérification. |
| `403 origin_not_allowed`, ou tous les envois finissent en spam | Le domaine de la page n'est pas dans les **Allowed domains** du formulaire. Ajoutez-le, plus `localhost` pour les tests en local. Les sous-domaines d'un domaine listé sont autorisés. |
| `429 rate_limited` | Un visiteur a envoyé plus que ce que permet la limite de débit du formulaire. Standard autorise 5 envois par minute et par visiteur. Attendez la durée de `Retry-After`, ou choisissez Relaxed ou Custom pour les formulaires qui envoient depuis une seule IP partagée. |
| `403 captcha_failed` | Le token Turnstile était absent, expiré ou utilisé deux fois. Un token vit 300 secondes et ne sert qu'une fois : affichez donc un nouveau widget, ou rappelez `challenge()`, après chaque tentative. |
| Le spam arrive toujours comme reçu | Ouvrez-en un et voyez ce qu'il a en commun avec les autres. Le spam scripté demande de régler **Spam check** sur **Ikiform** ; les démarchages tapés à la main demandent une limite de débit plus stricte et une relecture hebdomadaire. |
| De vrais visiteurs disent ne pas pouvoir envoyer | Vérifiez que le honeypot est masqué aux lecteurs d'écran et nommé de façon à ce que le remplissage automatique l'ignore, et qu'une limite de débit stricte n'attrape pas des personnes derrière l'IP d'un même bureau. |

## Questions

### Quelle est la meilleure façon d'arrêter le spam des formulaires de contact ?

Des couches, pas une seule astuce. Un honeypot masqué, un délai minimum pour remplir le formulaire, un score du contenu et une limite de débit arrêtent la plupart du spam sans rien coûter aux vrais visiteurs. Ajoutez une vérification humaine comme Cloudflare Turnstile si du spam passe encore, et vérifiez son token sur un serveur.

### Ai-je besoin d'un captcha sur mon formulaire de contact ?

Pas au début. La plupart du spam des formulaires de contact vient de bots simples qu'un honeypot et une limite de débit arrêtent. Ajoutez un captcha quand votre dossier de spam montre que des bots passent encore, et préférez un captcha invisible comme Turnstile aux puzzles d'images.

### Pourquoi est-ce que je reçois du spam de vraies personnes ?

Une partie du spam est collée par des personnes payées au formulaire, souvent des démarchages SEO ou de développement. Les vérifications anti-bot ne peuvent pas arrêter une personne. Le score du contenu en attrape beaucoup grâce à leurs liens et formules ; le reste, vous le supprimez dans le dossier de spam.

### Ikiform prévient-il l'expéditeur que son message a été marqué comme spam ?

Non. Le spam reçoit la même réponse de succès qu'un vrai message : les bots n'apprennent donc rien. Il est conservé 30 jours, n'envoie ni e-mail ni webhook, et vous pouvez le marquer comme non-spam depuis le tableau de bord.

### Ai-je besoin d'un compte Cloudflare pour utiliser Turnstile avec Ikiform ?

Non. Réglez le Spam check du formulaire sur Ikiform et il exécute Turnstile sur ikiform.com, sans clés à gérer. N'utilisez vos propres clés Turnstile que si vous voulez votre propre widget sur votre page.

### Un honeypot peut-il nuire à l'accessibilité ?

Oui, s'il est masqué à la légère. Masquez-le hors écran avec aria-hidden sur son conteneur, retirez-le de l'ordre de tabulation avec tabindex -1, et donnez-lui un nom que les navigateurs ne remplissent pas automatiquement.

### Ikiform est-il gratuit ?

Non, il n'y a pas d'offre gratuite. Les tests sont gratuits : vous pouvez envoyer 50 soumissions de test par mois depuis localhost avant de payer. Pour passer en production, Pro commence à 6 $/mois pour 500 soumissions, ou vous pouvez payer une seule fois 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais.

## Autres guides

- [Champ honeypot](https://ikiform.com/fr/guides/spam-protection/honeypot.md): 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.
- [Cloudflare Turnstile](https://ikiform.com/fr/guides/spam-protection/cloudflare-turnstile.md): 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.
- [Sans captcha](https://ikiform.com/fr/guides/spam-protection/without-captcha.md): Arrêtez l'essentiel du spam sans captcha : un honeypot, une heure de rendu signée, un score du contenu et une limite de débit, cumulés plutôt qu'utilisés seuls.
- [Alternatives à reCAPTCHA](https://ikiform.com/fr/guides/spam-protection/recaptcha-alternatives.md): Comparez les alternatives à reCAPTCHA pour formulaires, de Turnstile et hCaptcha à la preuve de travail ou à l'absence de captcha, puis migrez avec un petit changement sûr.
- [Limitation de débit](https://ikiform.com/fr/guides/spam-protection/rate-limiting.md): Limitez les envois de formulaire par visiteur avec un stockage partagé, répondez 429 avec Retry-After, et évitez de bloquer les bureaux qui partagent une IP.
- [Sites statiques](https://ikiform.com/fr/guides/spam-protection/static-site.md): 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.
- [Spam des formulaires Webflow](https://ikiform.com/fr/guides/spam-protection/webflow.md): Protégez un formulaire Webflow qui envoie vers un backend de formulaires, où les réglages anti-spam de Webflow ne s'appliquent pas, avec un honeypot et une vérification humaine.
- [Next.js](https://ikiform.com/fr/guides/spam-protection/nextjs.md): Protégez un formulaire Next.js du spam : honeypot, Turnstile vérifié dans une server action, limite par IP sur Vercel, Netlify ou Cloudflare, et une CSP adaptée.
- [Astro](https://ikiform.com/fr/guides/spam-protection/astro.md): Protégez un formulaire Astro du spam avec une Action : un honeypot, Turnstile vérifié avec les secrets astro:env, une limite par IP et la CSP intégrée d'Astro.
- [TanStack Start](https://ikiform.com/fr/guides/spam-protection/tanstack-start.md): Protégez un formulaire TanStack Start du spam : un middleware de fonction pour l'IP client, un token bucket, Turnstile vérifié dans une server function, et une CSP.
- [React Router](https://ikiform.com/fr/guides/spam-protection/react-router.md): Protégez un formulaire React Router du spam : une action de route avec Turnstile, une limite Workers basée sur cf-connecting-ip, un module .server et un en-tête CSP.
- [SvelteKit](https://ikiform.com/fr/guides/spam-protection/sveltekit.md): Protégez un formulaire SvelteKit du spam : une form action avec getClientAddress, une limite par IP, Turnstile derrière use:enhance, et une CSP qui l'autorise.
- [Nuxt](https://ikiform.com/fr/guides/spam-protection/nuxt.md): Protégez un formulaire Nuxt du spam : une server route Nitro avec Turnstile, une limite par IP dans le stockage Nitro, des secrets runtimeConfig et une CSP dans routeRules.
- [Dépasser les formulaires intégrés](https://ikiform.com/fr/guides/website-builder-forms.md): Quand les formulaires de Webflow, Netlify, Carrd ou Framer atteignent un plafond ou manquent d'une fonctionnalité, gardez le formulaire que vous avez conçu et faites-le pointer vers un backend de formulaires.
- [Formulaire de feedback pour un site ou une application](https://ikiform.com/fr/guides/feedback-form.md): Placez un formulaire de feedback sur n'importe quelle page ou dans votre application : une note, un commentaire, la page d'origine, le filtrage du spam et des alertes quand une note est basse.
- [Formulaire de capture de leads](https://ikiform.com/fr/guides/lead-capture.md): Un formulaire de capture de leads sur votre propre site, qui vous écrit avec le lead en adresse de réponse, alerte le chat de l'équipe et alimente votre CRM, pour répondre tant que le lead est encore là.
