# 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.

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

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.

Schéma : L'ordre des vérifications dans la server action Next.js

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`:

```text
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`:

```bash
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 :

Schéma : Quel en-tête porte la vraie IP du client chez chaque hébergeur

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`:

```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`:

```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`:

```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](https://ikiform.com/fr/guides/spam-protection/rate-limiting.md).
- 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](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.

## Le formulaire et le widget

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

```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](https://ikiform.com/fr/guides/spam-protection/honeypot.md) masque le honeypot hors écran.

`app/contact/page.tsx`:

```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`:

```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ébergeur | CLIENT_IP_HEADER | Limite de débit | Remarques |
| --- | --- | --- | --- |
| Vercel | `x-real-ip` | Règle Firewall et `checkRateLimit` | Ajoutez une règle par environnement de test |
| Netlify | `x-nf-client-connection-ip` | Redis, comme Upstash | Les server actions tournent dans une seule fonction Netlify |
| Cloudflare, avec OpenNext | `cf-connecting-ip` | Binding de limitation de débit Workers | Le 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`:

```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](https://ikiform.com/fr/docs/guides/nextjs.md) 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ème | Que faire |
| --- | --- |
| Le widget n'apparaît jamais après une navigation vers la page | Le 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 production | `TURNSTILE_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ébit | `CLIENT_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

### Comment ajouter Cloudflare Turnstile à une server action Next.js ?

Affichez le widget dans un composant client, chargez le script avec next/script, et lisez le champ cf-turnstile-response dans la server action. Envoyez-le avec votre clé secrète à siteverify et ne continuez que si la réponse indique success avec l'action et le hostname attendus.

### Comment obtenir l'IP du client dans une server action Next.js ?

Lisez-la dans l'en-tête que définit votre hébergeur avec headers() de next/headers : x-real-ip ou x-forwarded-for sur Vercel, x-nf-client-connection-ip sur Netlify, cf-connecting-ip sur Cloudflare. Next.js n'a pas de request.ip dans l'App Router.

### Un nonce CSP empêche-t-il la page de contact d'être statique ?

Oui. Un nonce est créé à chaque requête : la page doit donc être rendue à chaque requête. Gardez la page de contact dynamique, ou utilisez une politique sans nonce qui autorise challenges.cloudflare.com par hôte.

### 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

- [Arrêter le spam des formulaires de contact](https://ikiform.com/fr/guides/spam-protection.md): 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.
- [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.
- [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.
- [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.
- [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.
