Arrêter le spam des formulaires de contact

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

Protection anti-spam d'un formulaire Next.js avec Turnstile

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.

Preet SutharFondateur, Ikiform

Dans Next.js, vérifiez le spam dans la server action, car l'action est un endpoint POST public que n'importe qui peut appeler sans votre formulaire. Limitez le débit par l'IP client que définit votre hébergeur, vérifiez le token Turnstile avec siteverify, puis transmettez les champs avec le honeypot intact. Chargez le script Turnstile via next/script avec le nonce de votre CSP, et autorisez challenges.cloudflare.com dans frame-src.

Ce qui change avec Next.js

Une server action ressemble à un appel de fonction, mais Next.js la transforme en endpoint POST avec un ID dans le JavaScript de la page. Un bot n'a pas besoin de votre formulaire. Il peut rejouer ce POST avec les champs qu'il veut, aussi vite qu'il veut. Chaque vérification a donc sa place dans l'action, jamais uniquement dans le composant.

Trois détails de Next.js façonnent le reste de cette page :

  • Il n'y a pas de request.ip. L'IP du client vient d'un en-tête, et celui auquel vous pouvez vous fier dépend de l'hébergeur.
  • Un nonce implique un rendu dynamique. Un nonce par requête ne peut pas s'appliquer à une page construite au build.
  • La navigation côté client garde la page vivante. Un script Turnstile chargé une fois n'affichera pas de nouveau widget après une navigation douce.
L'ordre des vérifications dans la server action Next.jsserveractionun POSTque toutle mondepeutenvoyerlimitededébitrègleVercelFirewall,par IPsiteverifytoken,action,hostnameIkiformnote lehoneypot,stockeétatd'erreurrienn'esttransmissous la limitehumainau-delà de la limiteL'ordre des vérifications dans la server action Next.jsserver actionun POST que tout lemonde peut envoyerlimite de débitrègle VercelFirewall, par IPsiteverifytoken, action,hostnameIkiformnote le honeypot,stockeétatd'erreurrien n'esttransmissous la limitehumainau-delà de la limite
  1. Le formulaire, ou un script qui le rejoue, envoie vers la server action.
  2. L'action compare le compteur du visiteur à une règle du Vercel Firewall. Au-delà de la limite, elle renvoie un état d'erreur et s'arrête.
  3. Elle envoie le token Turnstile et l'IP du client à siteverify, et vérifie le nom de l'action et le hostname dans la réponse.
  4. Seulement ensuite, elle transmet les champs, honeypot compris, à Ikiform, qui attribue un score au message et le stocke.
La vérification la moins coûteuse passe en premier. Le honeypot suit tel quel : un honeypot rempli est donc stocké comme spam plutôt que jeté en silence.

Mise en place du projet

Les fichiers que cette page ajoute à un projet App Router :

Fichiers
app/
  contact/
    page.tsx
    contact-form.tsx
    actions.ts
lib/
  client-ip.ts
  turnstile.ts
proxy.ts
.env.local

Créez un widget Turnstile dans le tableau de bord Cloudflare et listez chaque hostname où tourne le formulaire. Pendant le développement, utilisez les clés de test de Cloudflare, qui passent sur n'importe quel hôte, y compris localhost :

.env.local
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
TURNSTILE_HOSTNAME=
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-real-ip
  • Seule NEXT_PUBLIC_TURNSTILE_SITE_KEY atteint le navigateur. Next.js insère les valeurs NEXT_PUBLIC_ au build : changer la site key demande donc un nouveau build.
  • TURNSTILE_HOSTNAME reste vide en local, car les tokens de test ne portent pas votre hostname. Réglez-la sur votre vrai domaine en production.
  • Remplacez le secret par 2x0000000000000000000000000000000AA pour voir chaque vérification échouer.

Lire l'IP client que définit votre hébergeur

Une limite de débit basée sur un en-tête que le visiteur peut écrire n'est pas une limite. Chaque hébergeur définit un en-tête qu'il écrase à l'entrée, et c'est le seul auquel se fier :

Quel en-tête porte la vraie IP du client chez chaque hébergeurrequête entranteclientIp()lit CLIENT_IP_HEADERVercelx-real-ip oux-forwarded-forNetlifyx-nf-client-connection-ipCloudflarecf-connecting-ipQuel en-tête porte la vraie IP du client chez chaque hébergeurrequêteentranteclientIp()litCLIENT_IP_HEADERVercelx-real-ip oux-forwarded-forNetlifyx-nf-client-connection-ipCloudflarecf-connecting-ip
  1. clientIp() lit le seul en-tête nommé dans CLIENT_IP_HEADER et ignore les autres.
  2. Vercel écrase x-forwarded-for et x-real-ip avec l'adresse du visiteur.
  3. Netlify place l'adresse dans x-nf-client-connection-ip.
  4. Cloudflare, y compris Next.js déployé avec OpenNext, définit cf-connecting-ip.
Vercel documente qu'il écrase X-Forwarded-For pour empêcher l'usurpation. La documentation de Netlify ne cite que context.ip dans les Functions ; x-nf-client-connection-ip est l'en-tête qu'indique l'équipe de Netlify.
lib/client-ip.ts
import "server-only";
import { headers } from "next/headers";

const HEADER = process.env.CLIENT_IP_HEADER ?? "x-real-ip";

export async function clientIp() {
  const value = (await headers()).get(HEADER);
  return value?.split(",")[0]?.trim() || null;
}

Vérifier Turnstile dans un helper côté serveur uniquement

lib/turnstile.ts
import "server-only";

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

export type Check = { ok: true } | { ok: false; reason: string };

export async function verifyTurnstile(token: string, ip: string | null): Promise<Check> {
  if (!token || token.length > 2048) return { ok: false, reason: "missing-input-response" };

  let verdict: Verdict;
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: token, remoteip: ip ?? undefined }),
      signal: AbortSignal.timeout(5000),
    });
    verdict = (await response.json()) as Verdict;
  } catch {
    return { ok: false, reason: "siteverify-unreachable" };
  }

  if (!verdict.success) return { ok: false, reason: verdict["error-codes"]?.join(",") || "failed" };
  if (verdict.action !== "contact") return { ok: false, reason: "wrong-action" };
  const expected = process.env.TURNSTILE_HOSTNAME;
  if (expected && verdict.hostname !== expected) return { ok: false, reason: "wrong-hostname" };
  return { ok: true };
}
  • Un token est valable 300 secondes et ne peut être vérifié qu'une fois ; siteverify renvoie ensuite timeout-or-duplicate.
  • success seul ne suffit pas. Un token émis sur un autre site avec votre site key indique aussi success : le helper vérifie donc action et, en production, hostname.
  • Il échoue de manière fermée. Si Cloudflare est injoignable, le visiteur voit une erreur et aucun bot ne passe.

La server action

app/contact/actions.ts
"use server";

import { checkRateLimit } from "@vercel/firewall";
import { headers } from "next/headers";
import { clientIp } from "@/lib/client-ip";
import { verifyTurnstile } from "@/lib/turnstile";

export interface ContactState {
  status: "idle" | "sent" | "error";
  message: string;
  fields?: { name: string; email: string; message: string };
}

export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
  const fields = {
    name: String(formData.get("name") ?? "").trim(),
    email: String(formData.get("email") ?? "").trim(),
    message: String(formData.get("message") ?? "").trim(),
  };

  if (process.env.VERCEL) {
    const { rateLimited } = await checkRateLimit("contact-form", { headers: await headers() });
    if (rateLimited) return { status: "error", message: "Too many messages from your connection. Try again in a minute.", fields };
  }

  const check = await verifyTurnstile(String(formData.get("cf-turnstile-response") ?? ""), await clientIp());
  if (!check.ok) {
    console.warn("turnstile", check.reason);
    return { status: "error", message: "We couldn't confirm you're human. Try the check again.", fields };
  }

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      Origin: (await headers()).get("origin") ?? "",
    },
    body: JSON.stringify({ ...fields, _gotcha: String(formData.get("_gotcha") ?? "") }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { status: "sent", message: "Thanks. We'll reply by email." };
  return { status: "error", message: result?.error?.message ?? "Your message didn't send. Try again.", fields };
}

Sur Vercel, la limite vient du Firewall. Ouvrez le Firewall de votre projet, ajoutez une règle dont la condition est @vercel/firewall, réglez le Rate limit ID sur contact-form, et choisissez une fenêtre comme 5 requêtes par minute. checkRateLimit prend les en-têtes de la requête : il fonctionne donc dans une server action où il n'y a pas d'objet Request, et il regroupe par IP client sauf si vous passez une rateLimitKey.

  • Vercel compte chaque règle par région : un trafic réparti sur plusieurs régions peut donc dépasser le nombre fixé. Pour un formulaire de contact, ce n'est pas grave ; pour un compte global exact, utilisez Redis, comme sur la page sur la limitation de débit.
  • L'en-tête Origin du visiteur est transmis car un fetch côté serveur n'en envoie aucun, et Ikiform le lit pour reconnaître les tests gratuits depuis localhost.
  • Hors de Vercel, l'appel au Firewall est sauté. Placez-y un limiteur Redis ou Workers, basé sur clientIp().
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.

Le formulaire et le widget

app/contact/contact-form.tsx
"use client";

import Script from "next/script";
import { useActionState, useEffect, useRef } from "react";
import { type ContactState, sendMessage } from "./actions";

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

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

const initial: ContactState = { status: "idle", message: "" };

export function ContactForm({ nonce }: { nonce?: string }) {
  const [state, action, pending] = useActionState(sendMessage, initial);
  const box = useRef<HTMLDivElement>(null);
  const widget = useRef<string | null>(null);

  function mount() {
    if (!box.current || !window.turnstile || widget.current) return;
    widget.current = window.turnstile.render(box.current, {
      sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!,
      action: "contact",
    });
  }

  useEffect(() => {
    if (state.status === "error" && widget.current) window.turnstile?.reset(widget.current);
  }, [state]);

  useEffect(
    () => () => {
      if (widget.current) window.turnstile?.remove(widget.current);
      widget.current = null;
    },
    [],
  );

  if (state.status === "sent") 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.fields?.name} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.fields?.email} />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} defaultValue={state.fields?.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>
      <div ref={box} />
      {state.status === "error" ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
      <Script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" nonce={nonce} onReady={mount} />
    </form>
  );
}
  • ?render=explicit empêche le script de parcourir la page à la recherche de widgets. onReady s'exécute au chargement du script puis à chaque montage du composant, ce qui couvre la navigation côté client.
  • Un token ne sert qu'une fois. Quand l'action renvoie une erreur, l'effet réinitialise le widget pour que la tentative suivante porte un nouveau token.
  • Le formulaire envoie aussi avant l'hydratation, car action est la server action elle-même. Sans JavaScript, il n'y a pas de widget : siteverify refuse donc l'envoi ; la vérification humaine a besoin d'un navigateur.
  • La classe .hp du guide du honeypot masque le honeypot hors écran.
app/contact/page.tsx
import { headers } from "next/headers";
import { ContactForm } from "./contact-form";

export default async function Page() {
  const nonce = (await headers()).get("x-nonce") ?? undefined;

  return (
    <main>
      <h1>Contact us</h1>
      <ContactForm nonce={nonce} />
    </main>
  );
}

Une CSP qui laisse Turnstile fonctionner

Next.js lit le nonce dans l'en-tête de requête Content-Security-Policy et l'ajoute à ses propres scripts et à tout <Script> doté de la prop nonce. D'après Cloudflare, Turnstile transmet le nonce à ce qu'il charge, et son iframe a besoin de frame-src https://challenges.cloudflare.com. Dans Next.js 16, le fichier est proxy.ts, qui a remplacé middleware.ts :

proxy.ts
import { type NextRequest, NextResponse } from "next/server";

export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const dev = process.env.NODE_ENV === "development";
  const policy = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://challenges.cloudflare.com${dev ? " 'unsafe-eval'" : ""}`,
    "frame-src https://challenges.cloudflare.com",
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' blob: data:",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
  ].join("; ");

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);
  requestHeaders.set("Content-Security-Policy", policy);
  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("Content-Security-Policy", policy);
  return response;
}

export const config = {
  matcher: [
    {
      source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};
  • Avec 'strict-dynamic', les navigateurs modernes font confiance au nonce et ignorent l'hôte, qui reste pour les navigateurs plus anciens.
  • Lire headers() dans la page force son rendu à chaque requête, ce qu'exige un nonce. Une page de contact construite statiquement ne peut pas en porter ; utilisez plutôt une politique sans nonce dans headers() de next.config, au prix de 'unsafe-inline'.

Déploiement

HébergeurCLIENT_IP_HEADERLimite de débitRemarques
Vercelx-real-ipRègle Firewall et checkRateLimitAjoutez une règle par environnement de test
Netlifyx-nf-client-connection-ipRedis, comme UpstashLes server actions tournent dans une seule fonction Netlify
Cloudflare, avec OpenNextcf-connecting-ipBinding de limitation de débit WorkersLe binding est approximatif et compté par emplacement

Réglez TURNSTILE_HOSTNAME sur votre vrai domaine en production. Les déploiements de préproduction ont leurs propres hostnames : ajoutez-les au widget ou laissez la vérification désactivée en préproduction.

Laisser Ikiform faire les vérifications

Tout ce qui précède existe parce que la server action transmet avec une clé secrète, ce qui désactive la vérification humaine et la limite par visiteur d'Ikiform. Envoyez plutôt depuis le navigateur et Ikiform exécute le honeypot, Turnstile et la limite de débit pour vous. Pas de clés Cloudflare, pas de changement dans proxy.ts au-delà d'autoriser https://ikiform.com dans frame-src et connect-src, et pas d'action :

app/contact/contact-form.tsx
"use client";

import { challenge, submit } from "@ikiform/sdk";
import { useActionState, useRef } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

export function ContactForm() {
  const frame = useRef<HTMLDivElement>(null);
  const [message, action, pending] = useActionState(async (_previous: string, data: FormData) => {
    try {
      const turnstileToken = await challenge(FORM_ID, { container: frame.current ?? undefined });
      const result = await submit(FORM_ID, data, { turnstileToken });
      return result.ok ? "sent" : result.error.message;
    } catch {
      return "The human check didn't finish. Try again.";
    }
  }, "");

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

  return (
    <form action={action}>
      <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} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <div ref={frame} />
      {message ? <p role="alert">{message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}

Réglez le Spam check du formulaire sur Ikiform et son Rate limit sur Strict, 2 toutes les 10 minutes par visiteur, et ajoutez votre domaine ainsi que localhost sous Allowed domains. challenge() monte un petit cadre ikiform.com qui reste invisible sauf si Turnstile demande un clic. Le spam reçoit la même réponse de succès qu'un vrai message, est stocké avec les raisons pour lesquelles il a été attrapé, et est conservé 30 jours avant d'être supprimé. Le guide d'installation Next.js explique comment créer le formulaire et ses clés.

Vérifiez que tout fonctionne

  1. Lancez next dev avec les clés de test et envoyez un message. Il arrive sous Submissions. Sans offre, un espace de travail accepte 50 soumissions de test par mois depuis localhost.
  2. Réglez TURNSTILE_SECRET_KEY sur le secret qui échoue toujours et envoyez. Le formulaire affiche l'erreur de vérification humaine et garde votre texte.
  3. Sur une préproduction Vercel avec la règle Firewall, envoyez six fois en une minute. Le sixième affiche le message de limite.
  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. Ouvrez la page avec la console visible. Il n'y a aucune violation de CSP pour challenges.cloudflare.com.

Pièges

ProblèmeQue faire
Le widget n'apparaît jamais après une navigation vers la pageLe script a été chargé sur une page précédente et ne parcourt plus la page. Faites le rendu depuis onReady, comme dans l'exemple, pas depuis onLoad.
Chaque message échoue à la vérification humaine en productionTURNSTILE_HOSTNAME ne correspond pas au hostname renvoyé par siteverify, souvent www. contre le domaine nu. Journalisez check.reason pour savoir lequel.
Tous les visiteurs partagent un même compteur de limite de débitCLIENT_IP_HEADER nomme un en-tête que votre hébergeur ne définit pas : clientIp() renvoie donc null. Choisissez l'en-tête dans le tableau ci-dessus.
checkRateLimit renvoie error: "not-found"Aucune règle Firewall n'a le Rate limit ID contact-form dans cet environnement. Ajoutez-la, puis publiez les changements du Firewall.

Questions

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