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.
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.
- Le formulaire, ou un script qui le rejoue, envoie vers la server action.
- 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.
- 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.
- Seulement ensuite, elle transmet les champs, honeypot compris, à Ikiform, qui attribue un score au message et le stocke.
Mise en place du projet
Les fichiers que cette page ajoute à un projet App Router :
app/
contact/
page.tsx
contact-form.tsx
actions.ts
lib/
client-ip.ts
turnstile.ts
proxy.ts
.env.localCré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 :
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_KEYatteint le navigateur. Next.js insère les valeursNEXT_PUBLIC_au build : changer la site key demande donc un nouveau build. TURNSTILE_HOSTNAMEreste 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
2x0000000000000000000000000000000AApour 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 :
- clientIp() lit le seul en-tête nommé dans CLIENT_IP_HEADER et ignore les autres.
- Vercel écrase x-forwarded-for et x-real-ip avec l'adresse du visiteur.
- Netlify place l'adresse dans x-nf-client-connection-ip.
- Cloudflare, y compris Next.js déployé avec OpenNext, définit cf-connecting-ip.
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
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. successseul ne suffit pas. Un token émis sur un autre site avec votre site key indique aussisuccess: le helper vérifie doncactionet, 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
"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
Origindu visiteur est transmis car un fetch côté serveur n'en envoie aucun, et Ikiform le lit pour reconnaître les tests gratuits depuislocalhost. - Hors de Vercel, l'appel au Firewall est sauté. Placez-y un limiteur Redis ou Workers, basé sur
clientIp().
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Le formulaire et le widget
"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=explicitempêche le script de parcourir la page à la recherche de widgets.onReadys'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
actionest 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
.hpdu guide du honeypot masque le honeypot hors écran.
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 :
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 dansheaders()denext.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 :
"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
- Lancez
next devavec 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 depuislocalhost. - Réglez
TURNSTILE_SECRET_KEYsur le secret qui échoue toujours et envoyez. Le formulaire affiche l'erreur de vérification humaine et garde votre texte. - Sur une préproduction Vercel avec la règle Firewall, envoyez six fois en une minute. Le sixième affiche le message de limite.
- 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. - 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
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.
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.
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.
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.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.