Arrêter le spam des formulaires de contact8 min de lecture
Protection anti-spam d'un formulaire React Router avec Turnstile
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.
Faites les vérifications dans l'action de la route : limitez par l'IP client, vérifiez le token Turnstile avec siteverify, puis transmettez les champs avec le honeypot. Gardez les helpers dans un module .server.ts pour qu'ils n'atteignent jamais le navigateur, et définissez la CSP depuis l'export headers de la route. Sur Cloudflare Workers, le binding de limitation de débit et les secrets viennent de env dans cloudflare:workers.
Ce qui change avec React Router
En mode framework, chaque route dotée d'une action accepte des POST, de votre formulaire ou de n'importe qui d'autre. React Router vous donne trois outils qui déterminent la place des vérifications anti-spam :
- Les modules
.server. Un fichier nomméspam.server.tsne peut pas être inclus dans le code client : les secrets et les appels à siteverify restent donc sur le serveur même quand un composant importe depuis la même route. - L'export
headers. Les en-têtes renvoyés par une action ne sont pas envoyés sur une réponse de document, sauf si la fonctionheadersde la route les transmet. La CSP vit là aussi. useFetcher. Il envoie vers l'action sans navigation : la page et son widget restent donc montés entre les tentatives. C'est pourquoi le widget est remonté avec unekeyaprès chaque réponse.
Cette page déploie sur Cloudflare Workers, un hébergement courant pour React Router, où un binding de limitation de débit est intégré. Le tableau de déploiement couvre Vercel et Netlify.
- Une fois hydraté, le formulaire envoie via useFetcher : la page ne navigue donc pas.
- L'action limite le débit par IP, vérifie le token Turnstile et transmet les champs.
- Son résultat arrive dans fetcher.data.
- La key du widget change : un nouveau widget, et un nouveau token, est donc prêt pour la tentative suivante.
- Avant l'hydratation, le même formulaire envoie une requête de document normale, sans token Turnstile.
- L'action la refuse à l'étape siteverify.
- L'export headers transmet Retry-After et la CSP à la réponse de document.
- Le composant lit actionData au lieu de fetcher.data et affiche l'erreur.
Mise en place du projet
Partez du modèle React Router de Cloudflare avec @cloudflare/vite-plugin. Dans React Router 8, l'argument context est toujours un RouterContextProvider, et un simple objet { cloudflare: { env } } venant de l'entrée du Worker lève une erreur : l'entrée passe donc la requête seule et la route lit les bindings avec import { env } from "cloudflare:workers". Les fichiers que touche cette page :
app/
routes/
contact.tsx
components/
turnstile.tsx
lib/
spam.server.ts
workers/
app.ts
wrangler.jsonc
.dev.varsimport { createRequestHandler } from "react-router";
const handler = createRequestHandler(() => import("virtual:react-router/server-build"), import.meta.env.MODE);
export default {
fetch(request) {
return handler(request);
},
} satisfies ExportedHandler<Env>;{
"name": "acme-site",
"main": "./workers/app.ts",
"vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
"ratelimits": [
{
"name": "CONTACT_LIMIT",
"namespace_id": "1001",
"simple": { "limit": 5, "period": 60 }
}
]
}TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key- La site key est publique : elle se trouve donc dans
vars. Le loader la transmet au composant : le build client n'a donc besoin d'aucune variable d'environnement. - Les secrets vont dans
.dev.varsen local et viawrangler secret puten production, jamais danswrangler.jsonc. - Lancez
wrangler typesaprès avoir modifié la configuration, pour queCONTACT_LIMITet les secrets soient typés surenv.
Les helpers côté serveur uniquement
export async function isHuman(secret: string, token: string, ip: string, host: string) {
if (!token || token.length > 2048) return false;
const body = new URLSearchParams({ secret, response: token, remoteip: ip });
try {
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
return verdict.success && verdict.action === "contact" && (import.meta.env.DEV || verdict.hostname === host);
} catch {
return false;
}
}
export async function forward(secretKey: string, form: FormData, origin: string | null) {
const headers = new Headers({ Accept: "application/json", Authorization: `Bearer ${secretKey}` });
if (origin) headers.set("Origin", origin);
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", { method: "POST", headers, body: form }).catch(() => null);
return response?.ok ?? false;
}- Le suffixe
.serverfait échouer le build si du code client devait inclure ce module. - Siteverify accepte des corps form-encoded. La vérification du hostname compare avec l'hôte vers lequel le visiteur a envoyé et est sautée en développement, car les tokens de test portent un hostname factice.
forwardenvoie leFormDatad'origine : chaque champ, honeypot compris, atteint donc Ikiform tel qu'il a été envoyé.- Il transmet aussi l'
Origindu visiteur, car un fetch côté serveur n'en envoie aucun et Ikiform le lit pour reconnaître les tests gratuits depuislocalhost.
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Le widget
import { useEffect, useRef } from "react";
const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
interface TurnstileApi {
render(container: HTMLElement, options: { sitekey: string; action: string }): string;
remove(widgetId: string): void;
}
declare global {
interface Window {
turnstile?: TurnstileApi;
}
}
export function Turnstile({ siteKey }: { siteKey: string }) {
const box = useRef<HTMLDivElement>(null);
useEffect(() => {
let widgetId: string | undefined;
const mount = () => {
if (box.current && window.turnstile) widgetId = window.turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
};
if (window.turnstile) mount();
else document.querySelector(`script[src="${SCRIPT}"]`)?.addEventListener("load", mount, { once: true });
return () => {
if (widgetId) window.turnstile?.remove(widgetId);
};
}, [siteKey]);
return (
<>
<script src={SCRIPT} async />
<div ref={box} />
</>
);
}React 19 remonte un <script> async dans le head et le charge une seule fois, quel que soit le nombre de rendus du composant. L'effet rend le widget explicitement, ce que Cloudflare recommande pour les applications monopages, et le retire au démontage.
La route
import { useEffect, useState } from "react";
import { data, useFetcher } from "react-router";
import { env } from "cloudflare:workers";
import { Turnstile } from "~/components/turnstile";
import { forward, isHuman } from "~/lib/spam.server";
import type { Route } from "./+types/contact";
const POLICY = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
"frame-src https://challenges.cloudflare.com",
"object-src 'none'",
"base-uri 'self'",
].join("; ");
export function loader() {
return { siteKey: env.TURNSTILE_SITE_KEY };
}
export async function action({ request }: Route.ActionArgs) {
const ip = request.headers.get("cf-connecting-ip") ?? "unknown";
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
if (!success) {
return data({ ok: false, error: "Too many messages. Try again in a minute." }, { status: 429, headers: { "Retry-After": "60" } });
}
const form = await request.formData();
const token = String(form.get("cf-turnstile-response") ?? "");
if (!(await isHuman(env.TURNSTILE_SECRET_KEY, token, ip, new URL(request.url).hostname))) {
return data({ ok: false, error: "We couldn't confirm you're human. Try again." }, { status: 403 });
}
form.delete("cf-turnstile-response");
const sent = await forward(env.IKIFORM_SECRET_KEY, form, request.headers.get("origin"));
return sent ? { ok: true, error: "" } : data({ ok: false, error: "Your message didn't send. Try again." }, { status: 502 });
}
export function headers({ actionHeaders, parentHeaders }: Route.HeadersArgs) {
parentHeaders.set("Content-Security-Policy", POLICY);
const retry = actionHeaders.get("Retry-After");
if (retry) parentHeaders.set("Retry-After", retry);
return parentHeaders;
}
export default function Contact({ loaderData, actionData }: Route.ComponentProps) {
const fetcher = useFetcher<typeof action>();
const result = fetcher.data ?? actionData;
const [round, setRound] = useState(0);
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data) setRound((count) => count + 1);
}, [fetcher.state, fetcher.data]);
if (result?.ok) return <p role="status">Thanks. We'll reply by email.</p>;
return (
<fetcher.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 key={round} siteKey={loaderData.siteKey} />
{result?.error ? <p role="alert">{result.error}</p> : null}
<button disabled={fetcher.state !== "idle"}>{fetcher.state === "idle" ? "Send message" : "Sending"}</button>
</fetcher.Form>
);
}- La limite s'exécute avant
request.formData(): un flot ne coûte donc jamais d'analyse de corps ni d'appel à siteverify. cf-connecting-ipest défini par Cloudflare sur chaque requête vers un Worker et ne peut pas être défini par le visiteur.data()définit le statut etRetry-After. Un fetcher lit directement le corps ; c'est l'exportheadersqui porte ces en-têtes sur une réponse de document.resultse rabat suractionData: l'erreur s'affiche donc quand même si le formulaire a été envoyé avant l'hydratation.- La classe
.hpmasque le honeypot hors écran, comme dans le guide du honeypot.
React Router rend des scripts inline pour l'hydratation : cette politique garde donc 'unsafe-inline' dans script-src. Pour vous en passer, générez un nonce dans entry.server.tsx et passez-le à <Scripts> et au script Turnstile ; d'après Cloudflare, Turnstile transmet un nonce à ce qu'il charge.
Tester en local
D'après la documentation de Cloudflare, son plugin Vite exécute votre code serveur dans le runtime Workers pendant le développement : env de cloudflare:workers, .dev.vars et le binding de limitation de débit se comportent donc comme en production. En local, aucun edge Cloudflare ne définit cf-connecting-ip : chaque requête compte donc comme unknown et partage une même limite. C'est normal. Pour voir le vrai en-tête, déployez une préproduction avec wrangler versions upload et testez depuis deux réseaux.
Déployer ailleurs
| Hébergeur | IP client | Limiteur | Secrets |
|---|---|---|---|
| Cloudflare Workers | cf-connecting-ip | Binding de limitation de débit | wrangler secret put |
| Vercel | x-forwarded-for, écrasé par Vercel | Redis | process.env |
| Netlify | x-nf-client-connection-ip | Redis | process.env |
Cloudflare documente le binding comme approximatif et compté par emplacement, ce qui convient pour un formulaire de contact. Hors de Workers, il n'y a pas de module cloudflare:workers : lisez donc les secrets dans process.env et remplacez le binding par un compteur Redis comme celui de la page sur la limitation de débit.
Laisser Ikiform faire les vérifications
Si gérer des clés, un binding et une CSP pour un seul formulaire, c'est plus que vous n'en voulez, faites pointer un formulaire simple vers votre endpoint Ikiform et supprimez l'action :
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<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 />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<button>Send message</button>
</form>Utilisez un <form> en minuscules, pas le <Form> de React Router, pour que le navigateur envoie directement vers Ikiform. Réglez Spam check sur Ikiform : un envoi sans token reçoit une courte page de vérification sur ikiform.com qui exécute Turnstile et transmet les champs. Choisissez le Rate limit Strict, 2 toutes les 10 minutes par visiteur, et listez votre domaine sous Allowed domains. Le spam attrapé reçoit la même réponse qu'un vrai message et est conservé 30 jours. Le guide d'installation serveur explique comment garder plutôt votre propre action.
Vérifiez que tout fonctionne
- Lancez
npm run devet envoyez un message avec les clés de test. Il apparaît sous Submissions. Un espace de travail sans offre accepte 50 tests depuislocalhostpar mois. - Envoyez un second message sans recharger. Un nouveau widget apparaît après la première réponse et le second envoi réussit.
- Désactivez JavaScript et envoyez. La page se recharge avec l'erreur de vérification humaine.
- Envoyez six fois en une minute après le déploiement. Le sixième reçoit
429avecRetry-After: 60. - 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.
Pièges
| Problème | Que faire |
|---|---|
Server-only module referenced by client | Un composant importe depuis spam.server.ts quelque chose qu'il affiche. Seuls loader et action peuvent l'utiliser. |
| La deuxième tentative échoue toujours à siteverify | Le widget a gardé son token déjà utilisé. Assurez-vous que sa key change après chaque réponse du fetcher. |
Invalid context value provided to handleRequest | L'entrée du Worker passe encore { cloudflare: { env, ctx } }, comme le faisaient les modèles React Router 7. Appelez le handler avec la requête seule et lisez les bindings depuis cloudflare:workers. |
Retry-After manque dans la réponse sans JavaScript | Les en-têtes de l'action sont perdus sauf si l'export headers les renvoie. Copiez-les depuis actionHeaders comme montré. |
Questions
Affichez le widget dans le composant de la route, lisez cf-turnstile-response dans les données du formulaire dans l'action, et envoyez-le avec votre clé secrète à siteverify. Ne continuez que si la réponse indique success avec l'action définie sur le widget.
Déclarez un binding de limitation de débit dans wrangler.jsonc et appelez limit avec une clé construite à partir de l'en-tête cf-connecting-ip en haut de l'action. Renvoyez un 429 avec data() en cas d'échec.
Sur une requête de document, React Router n'envoie que les en-têtes que renvoie l'export headers de votre route. Lisez-y actionHeaders et copiez ce dont vous avez besoin, comme Retry-After.
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.