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

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

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.

Schéma : Un envoi de contact via la server route Nuxt et le stockage Nitro

1. Le composant envoie les champs et le token Turnstile vers /api/contact avec $fetch.
2. La server route lit l'IP client dans l'en-tête nommé dans runtimeConfig.
3. 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.
4. Sous la limite, elle vérifie le token avec siteverify, en contrôlant l'action et l'hôte.
5. Puis elle transmet les champs à Ikiform, qui les stocke et note le honeypot.

En développement, le montage de stockage est en mémoire. En production, le même code parle à Redis, car nuxt.config monte un autre driver.

## Mise en place du projet

`Fichiers`:

```text
app/
  components/
    ContactForm.vue
  composables/
    useTurnstile.ts
server/
  api/
    contact.post.ts
  utils/
    spam.ts
nuxt.config.ts
.env
```

`.env`:

```bash
NUXT_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-for
```

`nuxt.config.ts`:

```ts
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 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 `runtimeConfig` peut être remplacée à l'exécution par une variable `NUXT_` : `turnstileSecretKey` par `NUXT_TURNSTILE_SECRET_KEY`, `public.turnstileSiteKey` par `NUXT_PUBLIC_TURNSTILE_SITE_KEY`. Les clés hors de `public` n'atteignent jamais le navigateur.
- `routeRules` n'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 communautaire `nuxt-security` peut générer des nonces si vous avez besoin d'une politique plus stricte.
- Le driver `upstash` n'est monté qu'en production et lit `UPSTASH_REDIS_REST_URL` et `UPSTASH_REDIS_REST_TOKEN` dans l'environnement. Il a besoin du paquet `@upstash/redis` : installez-le avec `npm install @upstash/redis`.

## Utilitaires serveur

Les fichiers de `server/utils` sont importés automatiquement dans chaque server route :

`server/utils/spam.ts`:

```ts
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;
  }
}
```

- `clientIp` lit exactement un en-tête, celui que votre hébergeur écrase. Réglez `NUXT_CLIENT_IP_HEADER` sur `x-forwarded-for` sur Vercel, `x-nf-client-connection-ip` sur Netlify et `cf-connecting-ip` sur 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.
- `$fetch` envoie le corps de siteverify en JSON, ce que siteverify accepte. La vérification de l'hôte utilise `getRequestHost` et est sautée en développement, car les tokens de test portent un hostname factice.

## La server route

`server/api/contact.post.ts`:

```ts
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.ts` fait que Nitro ne répond qu'à `POST`. Tout le reste reçoit `405`.
- 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 porte `message`, que le composant affiche.
- L'`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`.
- Le honeypot `_gotcha` suit tel quel : Ikiform stocke donc un honeypot rempli comme spam au lieu de le jeter.

> 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 composable et le formulaire

`app/composables/useTurnstile.ts`:

```ts
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 };
}
```

`app/components/ContactForm.vue`:

```vue
<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 jamais `window`.
- 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 `.hp` masque le honeypot hors écran, comme dans le [guide du honeypot](https://ikiform.com/fr/guides/spam-protection/honeypot.md).

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

`Terminal`:

```bash
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"}'
done
```

Les 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](https://ikiform.com/fr/docs/guides/nuxt.md) couvre l'endpoint et les clés.

## Vérifiez que tout fonctionne

1. Lancez `nuxt dev` avec les clés de test et envoyez un message. Il apparaît sous **Submissions**. Un espace de travail sans offre accepte 50 tests depuis `localhost` par mois.
2. Envoyez six messages en une minute. Le sixième indique combien de secondes il reste.
3. Réglez `NUXT_TURNSTILE_SECRET_KEY` sur le secret qui échoue toujours et redémarrez. Chaque envoi affiche l'erreur de vérification humaine.
4. Requêtez `/contact` avec `curl -I` après `nuxt build`. La réponse porte votre `Content-Security-Policy`.
5. 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

### Comment ajouter Turnstile à un formulaire Nuxt ?

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.

### Comment limiter le débit d'une server route Nuxt ?

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.

### getRequestIP est-il sûr pour limiter le débit dans Nuxt ?

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.

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