# Protection anti-spam d'un formulaire SvelteKit avec Turnstile

> Protégez un formulaire SvelteKit du spam : une form action avec getClientAddress, une limite par IP, Turnstile derrière use:enhance, et une CSP qui l'autorise.

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

Faites les vérifications dans une form action : limitez par `getClientAddress()`, vérifiez le token Turnstile avec siteverify, puis transmettez les champs avec le honeypot. Rendez le widget avec une action Svelte et réinitialisez-le dans le callback de `use:enhance`, car chaque token ne sert qu'une fois. Autorisez `challenges.cloudflare.com` dans l'option `csp` du plugin `sveltekit()`, et sur `adapter-node`, indiquez à SvelteKit quel en-tête du proxy contient la vraie IP.

## Ce qui change avec SvelteKit

SvelteKit vous donne directement l'IP du visiteur : `getClientAddress()` sur l'événement de requête. L'origine de cette valeur dépend de l'adaptateur, et sur `adapter-node` la valeur par défaut est fausse derrière n'importe quel proxy. Réglez ce point et le reste des vérifications trouve naturellement sa place dans une form action.

Schéma : Où getClientAddress() obtient l'IP du visiteur selon l'adaptateur

1. La form action demande l'adresse du client à l'événement de requête.
2. SvelteKit délègue la réponse à l'adaptateur avec lequel vous déployez.
3. adapter-node utilise l'adresse distante du socket, sauf si vous définissez ADDRESS_HEADER et XFF_DEPTH.
4. Les adaptateurs Vercel, Netlify et Cloudflare lisent l'adresse fournie par leur plateforme : il n'y a rien à configurer.

Derrière nginx ou un load balancer, adapter-node voit l'adresse du proxy pour chaque visiteur tant que vous ne définissez pas ADDRESS_HEADER, et une adresse partagée transforme une limite par IP en limite pour tout le site.

L'autre détail de SvelteKit est `use:enhance`. Il envoie sans rechargement de page, ce qui est plus agréable pour les personnes mais signifie que Turnstile ne se réaffiche jamais tout seul. Le widget doit être réinitialisé à la main après chaque réponse.

## Mise en place du projet

`Fichiers`:

```text
src/
  env.ts
  lib/
    turnstile.ts
    server/
      spam.ts
  routes/
    contact/
      +page.server.ts
      +page.svelte
vite.config.ts
.env
```

`.env`:

```bash
PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_token
```

`src/env.ts`:

```ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  PUBLIC_TURNSTILE_SITE_KEY: { public: true },
  TURNSTILE_SECRET_KEY: {},
  IKIFORM_SECRET_KEY: {},
  UPSTASH_REDIS_REST_URL: {},
  UPSTASH_REDIS_REST_TOKEN: {},
});
```

- SvelteKit 3 lit les variables que vous déclarez dans `src/env.ts`. Seule la site key a `public: true` : c'est donc la seule que `$app/env/public` peut transmettre au navigateur. Rien de ce qui se trouve dans un dossier `server`, comme `src/lib/server`, ne peut être importé par du code client ; SvelteKit fait échouer le build si vous essayez.
- Les variables déclarées sont lues au démarrage de l'application sauf si vous définissez `static: true` : une clé renouvelée demande donc un redémarrage, pas un rebuild. Les modules `$env/*` fonctionnent encore mais sont dépréciés dans SvelteKit 3.
- Les clés de test de Cloudflare passent sur n'importe quel hôte, y compris `localhost`.

`vite.config.ts`:

```ts
import adapter from "@sveltejs/adapter-node";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    sveltekit({
      adapter: adapter(),
      csp: {
        mode: "auto",
        directives: {
          "script-src": ["self", "https://challenges.cloudflare.com"],
          "frame-src": ["https://challenges.cloudflare.com"],
          "object-src": ["none"],
          "base-uri": ["self"],
        },
      },
    }),
  ],
});
```

SvelteKit 3 prend ses options dans `vite.config.ts`, passées au plugin `sveltekit()` ; `svelte.config.js` n'est plus lu. Avec `mode: "auto"`, SvelteKit ajoute des nonces à ses propres scripts inline sur les pages rendues côté serveur et des hashes sur les pages pré-rendues : la politique n'a donc pas besoin de `'unsafe-inline'`. Turnstile se charge depuis son hôte, que `script-src` autorise, et dessine une iframe que `frame-src` autorise.

## Les helpers côté serveur uniquement

`src/lib/server/spam.ts`:

```ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { dev } from "$app/env";
import { TURNSTILE_SECRET_KEY, UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "$app/env/private";

const limiter = new Ratelimit({
  redis: new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN }),
  limiter: Ratelimit.slidingWindow(5, "60 s"),
  prefix: "contact",
});

export async function secondsToWait(ip: string) {
  const { success, reset } = await limiter.limit(ip);
  return success ? 0 : Math.max(1, Math.ceil((reset - Date.now()) / 1000));
}

export async function isHuman(token: string, ip: string, host: string) {
  if (!token || token.length > 2048) return false;
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: TURNSTILE_SECRET_KEY, response: token, remoteip: ip }),
    });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    return verdict.success && verdict.action === "contact" && (dev || verdict.hostname === host);
  } catch {
    return false;
  }
}
```

- La limite est une fenêtre glissante dans Redis, partagée par toutes les instances du serveur, cinq messages par minute par IP. `reset` revient en millisecondes et devient un nombre entier de secondes à attendre.
- `dev` de `$app/env` saute la vérification du hostname en local, car les tokens de test portent un hostname factice.
- Siteverify répond `timeout-or-duplicate` pour un token de plus de 300 secondes ou déjà utilisé : c'est pourquoi la page réinitialise le widget.

## La form action

`src/routes/contact/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { isHuman, secondsToWait } from "#lib/server/spam.js";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, getClientAddress, url }) => {
    const ip = getClientAddress();
    const wait = await secondsToWait(ip);
    if (wait) return fail(429, { wait, error: `Too many messages. Try again in ${wait} seconds.` });

    const form = await request.formData();
    const values = {
      name: String(form.get("name") ?? "").trim(),
      email: String(form.get("email") ?? "").trim(),
      message: String(form.get("message") ?? "").trim(),
    };
    if (values.message.length < 10) return fail(400, { values, error: "A few words more, please." });
    if (!(await isHuman(String(form.get("cf-turnstile-response") ?? ""), ip, url.hostname))) {
      return fail(403, { values, error: "We couldn't confirm you're human. Try again." });
    }

    form.delete("cf-turnstile-response");
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: form,
    }).catch(() => null);
    if (!response?.ok) return fail(502, { values, error: "Your message didn't send. Try again." });
    return { sent: true };
  },
} satisfies Actions;
```

- La limite s'exécute avant l'analyse du corps. `fail(429)` garde le visiteur sur la page avec le message.
- Chaque autre échec renvoie `values` : les inputs se remplissent donc à nouveau après une erreur.
- Le `FormData` d'origine est transmis, honeypot compris, sans le token. Ikiform note un honeypot rempli et stocke le message comme spam.
- 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`.

> 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 widget sous forme d'action Svelte

`src/lib/turnstile.ts`:

```ts
const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

interface Api {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

let loading: Promise<Api> | undefined;

function load() {
  loading ??= new Promise<Api>((resolve, reject) => {
    const script = document.createElement("script");
    script.src = SCRIPT;
    script.async = true;
    script.onload = () => {
      const api = (window as Window & { turnstile?: Api }).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 turnstile(node: HTMLElement, sitekey: string) {
  let api: Api | undefined;
  load().then((loaded) => {
    api = loaded;
    node.dataset.widgetId = loaded.render(node, { sitekey, action: "contact" });
  });
  return {
    destroy() {
      if (node.dataset.widgetId) api?.remove(node.dataset.widgetId);
    },
  };
}

export async function resetTurnstile(node: HTMLElement | undefined) {
  const id = node?.dataset.widgetId;
  if (id) (await load()).reset(id);
}
```

Une action Svelte est l'endroit naturel pour un widget tiers : elle s'exécute au montage de l'élément, et `destroy` s'exécute à son retrait. L'ID du widget est stocké sur l'élément : la page peut donc réinitialiser précisément ce widget.

## La page

`src/routes/contact/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
  import { resetTurnstile, turnstile } from "#lib/turnstile.js";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let box = $state<HTMLDivElement>();
  let sending = $state(false);
</script>

{#if form?.sent}
  <p role="status">Thanks. We'll reply by email.</p>
{:else}
  <form
    method="POST"
    use:enhance={() => {
      sending = true;
      return async ({ update }) => {
        await update({ reset: false });
        sending = false;
        await resetTurnstile(box);
      };
    }}
  >
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.values?.name ?? ""} />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required value={form?.values?.message ?? ""}></textarea>
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div bind:this={box} use:turnstile={PUBLIC_TURNSTILE_SITE_KEY}></div>
    {#if form?.error}
      <p role="alert">{form.error}</p>
    {/if}
    <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
  </form>
{/if}
```

- `update({ reset: false })` applique le résultat et garde ce que le visiteur a tapé. Le comportement par défaut viderait le formulaire, comme le fait un navigateur.
- `resetTurnstile` s'exécute après chaque réponse, succès ou non. Le token utilisé disparaît et un nouveau est prêt.
- Sans JavaScript, le formulaire est quand même envoyé et l'action s'exécute quand même, mais aucun widget n'est rendu : siteverify le refuse donc avec un message clair.
- Une page avec des form actions ne peut pas être pré-rendue : cette route est donc toujours rendue côté serveur. Les autres pages peuvent rester pré-rendues.
- 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

Sous `vite dev`, aucun adaptateur n'est placé devant l'application : `getClientAddress()` renvoie donc votre adresse de loopback et tous les onglets locaux partagent un même compteur. Cela suffit pour vérifier le message, pas pour vérifier la configuration du proxy. Pour la tester, construisez avec `adapter-node` et exécutez le résultat avec les mêmes variables qu'en production :

`Terminal`:

```bash
npm run build
ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 node build
```

Envoyez quelques fois avec différentes valeurs de `X-Forwarded-For`. Avec `XFF_DEPTH=1`, SvelteKit lit l'adresse la plus à droite, celle qu'ajouterait votre proxy : une valeur inventée par le client à gauche ne change donc rien.

## Déploiement

Sur votre propre serveur avec `adapter-node` derrière un proxy, indiquez à SvelteKit où se trouve la vraie IP :

`Environnement`:

```bash
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1
```

La documentation de SvelteKit avertit que les en-têtes sont faciles à usurper et que lire l'adresse la plus à gauche de `X-Forwarded-For` est l'erreur classique. `XFF_DEPTH` compte les proxys de confiance depuis la droite : réglez-le donc sur le nombre de proxys que vous exploitez, et assurez-vous que l'application n'est pas joignable en les contournant. SvelteKit 3 a abandonné la variable `ORIGIN` d'adapter-node. Si votre proxy ne transmet pas l'hôte d'origine, définissez `paths.origin` dans les options de `sveltekit()` pour que la vérification de formulaire de SvelteKit connaisse votre URL publique.

| Adaptateur | IP client | Secrets |
| --- | --- | --- |
| `adapter-node` | `ADDRESS_HEADER` et `XFF_DEPTH` | Environnement du processus |
| `adapter-vercel` | Automatique | Variables d'environnement du projet |
| `adapter-netlify` | Automatique | Variables d'environnement du site |
| `adapter-cloudflare` | Automatique | `wrangler secret put` |

## Laisser Ikiform faire les vérifications

Pour ne rien gérer de tout cela vous-même, supprimez `+page.server.ts` et faites pointer l'`action` du formulaire vers votre endpoint Ikiform, sans `use:enhance`. La page peut alors être pré-rendue comme le reste d'un site statique.

Réglez le **Spam check** du formulaire sur **Ikiform** : un envoi sans token reçoit ainsi 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**. Ikiform conserve le spam attrapé pendant 30 jours : un faux positif se restaure donc d'un clic. Le [guide d'installation SvelteKit](https://ikiform.com/fr/docs/guides/sveltekit.md) couvre l'endpoint et les clés.

## Vérifiez que tout fonctionne

1. Lancez `vite 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 un second message sans recharger. Il réussit, ce qui montre que la réinitialisation du widget a fonctionné.
3. Envoyez six messages en une minute. Le sixième indique combien de secondes attendre.
4. Derrière votre proxy, journalisez `getClientAddress()` depuis deux réseaux. Vous voyez deux adresses différentes, pas celle de votre proxy.
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 |
| --- | --- |
| Tous les visiteurs sont limités ensemble sur `adapter-node` | `getClientAddress()` renvoie l'adresse de votre proxy. Définissez `ADDRESS_HEADER` et `XFF_DEPTH`. |
| Le deuxième envoi échoue toujours à la vérification humaine | Le widget a gardé son token déjà utilisé. Appelez `resetTurnstile` dans le callback de `use:enhance` après `update`. |
| Le build échoue parce qu'une page avec des actions est pré-rendue | La route de contact a hérité de `prerender = true` d'un layout. Les pages avec des actions ne peuvent pas être pré-rendues ; définissez `export const prerender = false` dans son `+page.server.ts`. |
| La zone du widget est vide et la console signale une violation de CSP | `script-src` ou `frame-src` ne contient pas `https://challenges.cloudflare.com` dans `csp.directives` de `vite.config.ts`. |

## Questions

### Comment obtenir l'IP du client dans SvelteKit ?

Appelez getClientAddress() sur l'événement de requête dans une form action, une fonction load ou un hook. L'adaptateur la fournit. Sur adapter-node derrière un proxy, définissez ADDRESS_HEADER et XFF_DEPTH, sinon vous obtenez l'adresse du proxy.

### Pourquoi Turnstile échoue-t-il au deuxième envoi avec use:enhance ?

use:enhance ne recharge pas la page : le widget garde donc le token déjà utilisé. Réinitialisez le widget dans le callback d'enhance après chaque réponse.

### La CSP de SvelteKit fonctionne-t-elle avec Turnstile ?

Oui. Ajoutez https://challenges.cloudflare.com à script-src et frame-src dans csp.directives, que SvelteKit 3 prend dans les options du plugin sveltekit() de vite.config.ts. Avec le mode auto, SvelteKit ajoute des nonces ou des hashes pour ses propres scripts.

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