Arrêter le spam des formulaires de contact8 min de lecture
Protection anti-spam d'un formulaire Nuxt avec Turnstile
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.
Envoyez le formulaire avec $fetch vers une server route qui compte les tentatives par IP client dans le stockage Nitro, vérifie le token Turnstile avec siteverify et transmet les champs avec le honeypot. Gardez les secrets dans un runtimeConfig privé, défini à l'exécution par des variables d'environnement NUXT_. Envoyez la CSP de la page de contact depuis routeRules, en autorisant challenges.cloudflare.com pour le script et son cadre.
Ce qui change avec Nuxt
Le serveur de Nuxt est Nitro, et Nitro apporte deux choses dont une vérification anti-spam a besoin. Les server routes de server/api sont de simples endpoints auxquels $fetch est déjà branché, et useStorage() vous donne un stockage clé-valeur dont vous choisissez le backend par environnement : la mémoire en développement, Redis ou un autre driver en production. La limite de débit devient ainsi un changement de configuration plutôt qu'une dépendance.
Deux habitudes de Nuxt posent problème ici. getRequestIP(event, { xForwardedFor: true }) renvoie la première entrée de X-Forwarded-For, que la documentation de h3 elle-même qualifie d'entrée client. Et les fichiers .env ne sont pas lus en production : les secrets doivent donc être définis comme variables d'environnement de la plateforme.
- Le composant envoie les champs et le token Turnstile vers /api/contact avec $fetch.
- La server route lit l'IP client dans l'en-tête nommé dans runtimeConfig.
- Elle compte les envois de l'IP pour la minute en cours dans le stockage Nitro. Au-delà de cinq, elle lève un 429 avec les secondes restantes.
- Sous la limite, elle vérifie le token avec siteverify, en contrôlant l'action et l'hôte.
- Puis elle transmet les champs à Ikiform, qui les stocke et note le honeypot.
Mise en place du projet
app/
components/
ContactForm.vue
composables/
useTurnstile.ts
server/
api/
contact.post.ts
utils/
spam.ts
nuxt.config.ts
.envNUXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
NUXT_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_CLIENT_IP_HEADER=x-forwarded-forconst 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 default defineNuxtConfig({
runtimeConfig: {
turnstileSecretKey: "",
ikiformSecretKey: "",
clientIpHeader: "x-forwarded-for",
public: {
turnstileSiteKey: "",
},
},
routeRules: {
"/contact": { headers: { "Content-Security-Policy": policy } },
},
$production: {
nitro: {
storage: {
ratelimit: { driver: "upstash" },
},
},
},
});- Chaque clé de
runtimeConfigpeut être remplacée à l'exécution par une variableNUXT_:turnstileSecretKeyparNUXT_TURNSTILE_SECRET_KEY,public.turnstileSiteKeyparNUXT_PUBLIC_TURNSTILE_SITE_KEY. Les clés hors depublicn'atteignent jamais le navigateur. routeRulesn'envoie la CSP que sur/contact. Nuxt insère inline son payload et ses scripts d'hydratation : cette politique garde donc'unsafe-inline'; le module communautairenuxt-securitypeut générer des nonces si vous avez besoin d'une politique plus stricte.- Le driver
upstashn'est monté qu'en production et litUPSTASH_REDIS_REST_URLetUPSTASH_REDIS_REST_TOKENdans l'environnement. Il a besoin du paquet@upstash/redis: installez-le avecnpm install @upstash/redis.
Utilitaires serveur
Les fichiers de server/utils sont importés automatiquement dans chaque server route :
import type { H3Event } from "h3";
const WINDOW_SECONDS = 60;
const LIMIT = 5;
export function clientIp(event: H3Event) {
const header = useRuntimeConfig(event).clientIpHeader;
return getRequestHeader(event, header)?.split(",")[0]?.trim() || "unknown";
}
export async function secondsToWait(ip: string) {
const storage = useStorage("ratelimit");
const window = Math.floor(Date.now() / 1000 / WINDOW_SECONDS);
const key = `contact:${ip}:${window}`;
const count = ((await storage.getItem<number>(key)) ?? 0) + 1;
await storage.setItem(key, count);
if (count <= LIMIT) return 0;
return (window + 1) * WINDOW_SECONDS - Math.floor(Date.now() / 1000);
}
export async function isHuman(event: H3Event, token: string, ip: string) {
if (!token || token.length > 2048) return false;
try {
const verdict = await $fetch<{ success: boolean; hostname?: string; action?: string }>("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: { secret: useRuntimeConfig(event).turnstileSecretKey, response: token, remoteip: ip },
});
return verdict.success && verdict.action === "contact" && (import.meta.dev || verdict.hostname === getRequestHost(event));
} catch {
return false;
}
}clientIplit exactement un en-tête, celui que votre hébergeur écrase. RéglezNUXT_CLIENT_IP_HEADERsurx-forwarded-forsur Vercel,x-nf-client-connection-ipsur Netlify etcf-connecting-ipsur Cloudflare.- Le compteur est une fenêtre fixe dans le stockage Nitro. La lecture puis l'écriture ne sont pas atomiques : deux envois au même instant peuvent donc tous deux passer sous la limite ; pour un formulaire de contact, c'est acceptable. Les clés n'expirent pas d'elles-mêmes : donnez donc une politique d'éviction à la base Redis.
$fetchenvoie le corps de siteverify en JSON, ce que siteverify accepte. La vérification de l'hôte utilisegetRequestHostet est sautée en développement, car les tokens de test portent un hostname factice.
La server route
interface ContactBody {
name?: string;
email?: string;
company?: string;
message?: string;
_gotcha?: string;
"cf-turnstile-response"?: string;
}
export default defineEventHandler(async (event) => {
const ip = clientIp(event);
const wait = await secondsToWait(ip);
if (wait) throw createError({ status: 429, message: `Too many messages. Try again in ${wait} seconds.` });
const body = await readBody<ContactBody>(event);
const fields = {
name: body.name?.trim().slice(0, 120) ?? "",
email: body.email?.trim() ?? "",
company: body.company?.trim().slice(0, 120) ?? "",
message: body.message?.trim().slice(0, 5000) ?? "",
_gotcha: body._gotcha ?? "",
};
if (!fields.email.includes("@")) throw createError({ status: 400, message: "Enter an address we can reply to." });
if (!(await isHuman(event, body["cf-turnstile-response"] ?? "", ip))) {
throw createError({ status: 403, message: "We couldn't confirm you're human. Try again." });
}
const origin = getRequestHeader(event, "origin");
await $fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${useRuntimeConfig(event).ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: fields,
}).catch((failure) => {
throw createError({
status: failure.status ?? 502,
message: failure.data?.error?.message ?? "Your message didn't send. Try again.",
});
});
return { sent: true };
});- Le suffixe
.post.tsfait que Nitro ne répond qu'àPOST. Tout le reste reçoit405. - La limite s'exécute avant
readBody: un flot ne coûte donc jamais d'analyse de corps ni d'appel à siteverify. - Chaque échec lève
createError. Le corps JSON portemessage, que le composant affiche. - L'
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. - Le honeypot
_gotchasuit tel quel : Ikiform stocke donc un honeypot rempli comme spam au lieu de le jeter.
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Le composable et le formulaire
interface TurnstileApi {
render(
container: HTMLElement,
options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
): string;
reset(widgetId: string): void;
remove(widgetId: string): void;
}
let loading: Promise<TurnstileApi> | undefined;
function load() {
loading ??= new Promise<TurnstileApi>((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
script.async = true;
script.onload = () => {
const api = (window as Window & { turnstile?: TurnstileApi }).turnstile;
if (api) resolve(api);
else reject(new Error("Turnstile did not load"));
};
script.onerror = () => reject(new Error("Turnstile did not load"));
document.head.append(script);
});
return loading;
}
export function useTurnstile(action: string) {
const { turnstileSiteKey } = useRuntimeConfig().public;
const box = ref<HTMLElement>();
const token = ref("");
let api: TurnstileApi | undefined;
let widgetId: string | undefined;
onMounted(async () => {
api = await load();
if (!box.value) return;
widgetId = api.render(box.value, {
sitekey: turnstileSiteKey,
action,
callback: (value) => (token.value = value),
"expired-callback": () => (token.value = ""),
});
});
onBeforeUnmount(() => {
if (widgetId) api?.remove(widgetId);
});
function reset() {
token.value = "";
if (widgetId) api?.reset(widgetId);
}
return { box, token, reset };
}<script setup lang="ts">
const fields = reactive({ name: "", email: "", company: "", message: "", _gotcha: "" });
const { box, token, reset } = useTurnstile("contact");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
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";
reset();
}
}
</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" autocomplete="name" required maxlength="120" />
<label for="email">Email</label>
<input id="email" v-model="fields.email" type="email" autocomplete="email" required />
<label for="company">Company <span>(optional)</span></label>
<input id="company" v-model="fields.company" autocomplete="organization" maxlength="120" />
<label for="message">Message</label>
<textarea id="message" v-model="fields.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" 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>- Le composable ne s'exécute que dans
onMounted: le rendu serveur ne touche donc jamaiswindow. - Le bouton attend un token, et
reset()en demande un nouveau après chaque échec, car un token ne sert qu'une fois et vit 300 secondes. - Ce formulaire a besoin de JavaScript. Pour une version qui envoie sans, voyez la section suivante.
- La classe
.hpmasque le honeypot hors écran, comme dans le guide du honeypot.
Tester en local
En développement, le montage ratelimit est en mémoire : le compteur se réinitialise donc au redémarrage de nuxt dev. Vous pouvez observer la limite sans navigateur. Les envois sans token échouent à siteverify avec 403, et chacun compte quand même :
for i in 1 2 3 4 5 6; do
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/contact \
-H "Content-Type: application/json" -d '{"email":"test@example.com"}'
doneLes cinq premières lignes affichent 403 et la sixième 429. En local, aucun proxy ne définit l'en-tête d'IP : chaque requête compte donc comme unknown ; c'est normal, et c'est pourquoi l'en-tête vit dans la configuration plutôt que dans le code.
Déploiement
| Hébergeur | NUXT_CLIENT_IP_HEADER | Driver de stockage |
|---|---|---|
| Vercel | x-forwarded-for | upstash, depuis le Vercel Marketplace |
| Netlify | x-nf-client-connection-ip | upstash ou redis |
| Cloudflare | cf-connecting-ip | upstash ; Workers KV est à cohérence éventuelle, donc les compteurs sont en retard |
| Serveur Node | L'en-tête que définit votre proxy | redis, ou la mémoire sur une seule instance |
Définissez chaque variable NUXT_ dans le tableau de bord de l'hébergeur. Nuxt les lit au démarrage du serveur : changer une clé demande donc un redémarrage, pas un rebuild. Ajoutez chaque hostname de production et de préproduction au widget Turnstile.
Laisser Ikiform faire les vérifications
Sans la server route, la page de contact peut être pré-rendue avec le reste d'un site nuxt generate. Faites pointer un formulaire simple vers votre endpoint Ikiform avec method="post" et action réglé sur l'URL de votre endpoint, et supprimez server/api/contact.post.ts ainsi que le composable.
Réglez le Spam check du formulaire 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 : votre CSP n'a donc besoin d'aucune entrée Cloudflare. Choisissez le Rate limit Strict, 2 toutes les 10 minutes par visiteur, et listez votre domaine sous Allowed domains. Ikiform conserve le spam attrapé pendant 30 jours avec les raisons pour lesquelles il a été attrapé. Le guide d'installation Nuxt couvre l'endpoint et les clés.
Vérifiez que tout fonctionne
- Lancez
nuxt devavec les clés de test et envoyez un message. Il apparaît sous Submissions. Un espace de travail sans offre accepte 50 tests depuislocalhostpar mois. - Envoyez six messages en une minute. Le sixième indique combien de secondes il reste.
- Réglez
NUXT_TURNSTILE_SECRET_KEYsur le secret qui échoue toujours et redémarrez. Chaque envoi affiche l'erreur de vérification humaine. - Requêtez
/contactaveccurl -Iaprèsnuxt build. La réponse porte votreContent-Security-Policy. - 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 |
|---|---|
| Les secrets sont vides en production | .env n'est pas lu à l'exécution en production. Définissez NUXT_TURNSTILE_SECRET_KEY et les autres dans l'environnement de l'hébergeur. |
| La limite se réinitialise à chaque déploiement ou instance | Le montage de stockage est toujours en mémoire. Vérifiez que $production.nitro.storage monte un driver partagé et que ses identifiants sont définis. |
| Un script contourne la limite en changeant les en-têtes | Vous lisez getRequestIP avec xForwardedFor: true chez un hébergeur qui n'écrase pas cet en-tête. Lisez l'en-tête propre à l'hébergeur via clientIp. |
window is not defined pendant le rendu serveur | Du code Turnstile s'est exécuté hors de onMounted. Gardez chaque accès à window dans les hooks de cycle de vie du composable. |
Questions
Rendez le widget explicitement dans onMounted, gardez son token dans une ref, et envoyez-le avec les champs vers une server route. La route l'envoie avec votre clé secrète issue de runtimeConfig à siteverify et ne continue qu'en cas de succès.
Comptez les requêtes par IP client avec useStorage de Nitro, monté sur Redis ou Upstash en production pour que toutes les instances partagent le compteur. Levez un 429 avec createError quand le compteur dépasse votre limite.
Pas avec xForwardedFor à true chez un hébergeur qui laisse passer le X-Forwarded-For du client, car la première entrée est ce que le client a envoyé. Lisez plutôt l'en-tête que votre hébergeur écrase.
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.