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

> Protégez un formulaire Astro du spam avec une Action : un honeypot, Turnstile vérifié avec les secrets astro:env, une limite par IP et la CSP intégrée d'Astro.

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

Placez les vérifications dans une Astro Action vers laquelle le formulaire envoie sans JavaScript : limitez par `context.clientAddress`, vérifiez le token Turnstile avec un secret issu de `astro:env/server`, puis transmettez les champs avec le honeypot. La page de contact doit être rendue à la demande : ajoutez donc un adaptateur et `prerender = false` sur cette page. Si vous préférez garder tout le site statique, envoyez le formulaire simple vers Ikiform et laissez-le faire les mêmes vérifications.

## Ce qui change avec Astro

Astro construit du HTML statique par défaut, et le HTML statique ne peut pas garder de secret. Un secret Turnstile, une clé secrète Ikiform et un compteur de limite de débit ont tous besoin de code qui s'exécute à chaque requête : la première décision sur un site Astro est donc de savoir si la page de contact est rendue à la demande.

Schéma : Choisir où tournent les vérifications anti-spam sur un site Astro

1. Décidez comment la page de contact est rendue.
2. Avec vos propres clés Turnstile, la page est rendue à la demande via un adaptateur.
3. Le formulaire envoie vers une Astro Action, qui limite par IP, vérifie le token et transmet les champs.
4. Ikiform stocke le message et note le honeypot.
5. Pour garder le site statique, le formulaire simple envoie directement à Ikiform, qui exécute le honeypot, sa vérification Turnstile gérée et la limite de débit.

Les deux voies arrêtent les mêmes bots. Celle du haut vous donne votre propre widget et votre propre limite ; celle du bas ne livre aucun code serveur.

Astro vérifie aussi par défaut l'origine des envois de formulaire vers les pages à la demande, via `security.checkOrigin`. Cela empêche la page d'un autre site d'envoyer vers la vôtre. Cela n'arrête pas un script, qui peut envoyer les en-têtes qu'il veut : les vérifications ci-dessous restent donc nécessaires.

## Mise en place du projet

`Fichiers`:

```text
src/
  actions/
    index.ts
  lib/
    limit.ts
    turnstile.ts
  pages/
    contact.astro
astro.config.mjs
.env
```

Ajoutez un adaptateur avec `npx astro add vercel`, `netlify` ou `cloudflare`. Déclarez chaque variable dans le schéma `astro:env`, pour qu'un secret manquant échoue à l'import et qu'un secret ne puisse jamais être importé dans du code client :

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

`astro.config.mjs`:

```js
import vercel from "@astrojs/vercel";
import { defineConfig, envField } from "astro/config";

export default defineConfig({
  adapter: vercel(),
  env: {
    schema: {
      PUBLIC_TURNSTILE_SITE_KEY: envField.string({ context: "client", access: "public" }),
      TURNSTILE_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      UPSTASH_REDIS_REST_URL: envField.string({ context: "server", access: "secret" }),
      UPSTASH_REDIS_REST_TOKEN: envField.string({ context: "server", access: "secret" }),
    },
  },
  security: {
    csp: {
      scriptDirective: {
        resources: ["'self'", "https://challenges.cloudflare.com"],
      },
      directives: ["frame-src https://challenges.cloudflare.com"],
    },
  },
});
```

- `access: "secret"` tient une valeur hors du bundle. `astro:env/server` refuse de se charger dans un script client.
- `scriptDirective.resources` remplace les valeurs par défaut d'Astro : listez donc de nouveau `'self'` à côté de Cloudflare. Astro hache toujours ses propres scripts.
- La CSP ne s'applique pas sous `astro dev`. Vérifiez-la avec `astro build` et `astro preview`.

## Une limite par IP

Dans une Action, `context.clientAddress` est l'IP du visiteur telle que votre adaptateur la rapporte depuis la plateforme : vous n'analysez donc jamais `X-Forwarded-For` vous-même. Elle n'existe que sur les routes à la demande et lève une erreur sur les routes pré-rendues. Ce compteur garde une clé Redis par IP et par minute : il fonctionne donc sur plusieurs instances serverless :

`src/lib/limit.ts`:

```ts
import { Redis } from "@upstash/redis";
import { UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "astro:env/server";

const redis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN });

export async function overLimit(ip: string, limit = 5, windowSeconds = 60) {
  const key = `contact:${ip}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
  const [count] = await redis.multi().incr(key).expire(key, windowSeconds).exec<[number, number]>();
  return count > limit;
}
```

Sur Cloudflare, Astro 7 lit les bindings depuis `cloudflare:workers` au lieu de `Astro.locals.runtime`. Un binding de limitation de débit Workers déclaré dans `wrangler.jsonc` remplace Redis sans appel réseau, au prix d'un compte approximatif par emplacement :

`src/lib/limit.ts`:

```ts
import { env } from "cloudflare:workers";

export async function overLimit(ip: string) {
  const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
  return !success;
}
```

## Vérifier le token

`src/lib/turnstile.ts`:

```ts
import { TURNSTILE_SECRET_KEY } from "astro:env/server";

export async function isHuman(token: string, ip: string, host: string) {
  const body = new URLSearchParams({ secret: TURNSTILE_SECRET_KEY, 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 };
    if (!verdict.success || verdict.action !== "contact") return false;
    return import.meta.env.DEV || verdict.hostname === host;
  } catch {
    return false;
  }
}
```

- Siteverify accepte les corps form-encoded : `URLSearchParams` n'a donc besoin d'aucun en-tête.
- Le hostname est comparé à `context.url.hostname`, l'hôte vers lequel le visiteur a réellement envoyé. Les tokens de test portent un hostname factice : la vérification est donc sautée sous `astro dev`.
- Un token est valable 300 secondes et ne sert qu'une fois. Un envoi de formulaire simple recharge la page, et Turnstile en émet un nouveau à chaque fois.

## L'action Astro

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
import { overLimit } from "../lib/limit";
import { isHuman } from "../lib/turnstile";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().trim().min(1, "Tell us your name.").max(120),
      email: z.email("Enter an address we can reply to."),
      message: z.string().trim().min(10, "A few words more, please.").max(5000),
      _gotcha: z.string().optional(),
      "cf-turnstile-response": z.string().max(2048).optional(),
    }),
    handler: async (input, context) => {
      const ip = context.clientAddress;
      if (await overLimit(ip)) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Too many messages. Try again in a minute." });
      }
      if (!(await isHuman(input["cf-turnstile-response"] ?? "", ip, context.url.hostname))) {
        throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try again." });
      }

      const { "cf-turnstile-response": _token, ...fields } = input;
      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify(fields),
      });
      if (!response.ok) throw new ActionError({ code: "BAD_GATEWAY", message: "Your message didn't send. Try again." });
      return { sent: true };
    },
  }),
};
```

- Zod s'exécute avant le handler : un envoi malformé ne coûte donc aucun appel Redis. Les erreurs de champ reviennent à la page via `isInputError`.
- Le honeypot `_gotcha` est facultatif dans le schéma et transmis tel quel. Ikiform note un honeypot rempli comme spam : vous pouvez donc quand même le relire.
- Le token est retiré avant la transmission. Ikiform n'a que faire d'un token que vous avez déjà vérifié.
- 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`.
- `TOO_MANY_REQUESTS` correspond à un `429` et `FORBIDDEN` à un `403`.

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

## La page

`src/pages/contact.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";
import { PUBLIC_TURNSTILE_SITE_KEY } from "astro:env/client";

export const prerender = false;

const result = Astro.getActionResult(actions.sendMessage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---

<main>
  <h1>Contact us</h1>
  {result?.data?.sent ? (
    <p role="status">Thanks. We'll reply by email.</p>
  ) : (
    <form method="POST" action={actions.sendMessage}>
      <label for="name">Name</label>
      <input id="name" name="name" autocomplete="name" required maxlength="120" />
      {fields.name && <p class="field-error">{fields.name[0]}</p>}
      <label for="email">Email</label>
      <input id="email" name="email" type="email" autocomplete="email" required />
      {fields.email && <p class="field-error">{fields.email[0]}</p>}
      <label for="message">Message</label>
      <textarea id="message" name="message" rows="5" required minlength="10"></textarea>
      {fields.message && <p class="field-error">{fields.message[0]}</p>}
      <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 class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
      {result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
      <button>Send message</button>
    </form>
  )}
</main>

<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>
```

Le formulaire fonctionne sans aucun JavaScript côté client. `action={actions.sendMessage}` envoie vers la même page avec le nom de l'action dans la query string ; Astro exécute l'action et `Astro.getActionResult()` lit le résultat pendant le rendu. Seule cette page est rendue à la demande. Le reste du site reste statique.

- Actualiser après un envoi échoué demande de renvoyer le formulaire. La documentation d'Astro suggère le modèle POST, redirection, GET si cela vous gêne, en transportant le résultat dans une session.
- Le script Turnstile utilise `is:inline` : Astro n'y touche donc pas. Il doit être chargé depuis l'URL de Cloudflare.
- Appeler l'action depuis un script client sur une page statique fonctionne aussi, mais vous devez alors appeler `turnstile.reset()` vous-même après chaque tentative.

## Déploiement

| Hébergeur | Adaptateur | Où vivent les secrets | Limite |
| --- | --- | --- | --- |
| Vercel | `@astrojs/vercel` | Variables d'environnement du projet | Redis |
| Netlify | `@astrojs/netlify` | Variables d'environnement du site | Redis |
| Cloudflare | `@astrojs/cloudflare` | `wrangler secret put` | Binding de limitation de débit Workers |

Ajoutez votre hostname de production au widget Turnstile avant de déployer, et ajoutez aussi les hostnames de préproduction si vous y testez.

## Rester statique et laisser Ikiform vérifier

La plupart des sites Astro sont statiques, et ajouter un adaptateur uniquement pour un formulaire de contact a un vrai coût. Sans adaptateur, envoyez le formulaire directement vers votre endpoint Ikiform :

`src/components/Contact.astro`:

```astro
---
---

<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required minlength="10"></textarea>
  <div class="hp" aria-hidden="true">
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
  </div>
  <button>Send message</button>
</form>
```

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 : la page n'a donc besoin ni de script, ni de clés, ni de changement de CSP. Choisissez le **Rate limit** Strict, 2 toutes les 10 minutes par visiteur, et ajoutez votre domaine sous **Allowed domains**. Le spam reçoit la même réponse qu'un vrai message et est conservé 30 jours pour que vous puissiez repêcher un faux positif. Le [guide d'installation Astro](https://ikiform.com/fr/docs/guides/astro.md) montre le reste.

## Vérifiez que tout fonctionne

1. Lancez `astro 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. Laissez le nom vide après avoir retiré l'attribut `required` dans les outils de développement. L'erreur de champ s'affiche sous l'input.
3. Réglez `TURNSTILE_SECRET_KEY` sur le secret qui échoue toujours et envoyez. La page affiche l'erreur de vérification humaine.
4. Envoyez six fois en une minute. Le sixième répond `429` avec le message de limite.
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.
6. Lancez `astro build` et `astro preview`, ouvrez la page et vérifiez que la console n'affiche aucune violation de CSP.

## Pièges

| Problème | Que faire |
| --- | --- |
| `clientAddress` n'est pas disponible sur les pages pré-rendues | La page ou l'action a été exécutée au build. Ajoutez `export const prerender = false` à la page de contact. |
| Le résultat de l'action est toujours undefined | Le formulaire a envoyé vers une page pré-rendue. Les form actions ne s'exécutent que sur les pages rendues à la demande. |
| Le widget est vide en production mais fonctionne en dev | La CSP le bloque. `scriptDirective.resources` doit lister `https://challenges.cloudflare.com`, et `frame-src` doit aussi l'autoriser. |
| `403` sans message sur chaque envoi depuis un domaine personnalisé | `security.checkOrigin` a vu une origine différente, souvent à cause d'un proxy qui réécrit l'hôte. Assurez-vous que l'en-tête host qui atteint Astro correspond au domaine de la page. |
| La soumission est dans le tableau de bord mais aucun e-mail ni webhook n'est arrivé | Elle a été stockée comme spam. Le spam n'envoie rien. Ouvrez-la sous le filtre **Spam** pour voir les raisons, et marquez-la comme non-spam pour envoyer son e-mail et ses webhooks. |

## Questions

### Les Astro Actions fonctionnent-elles sur un site statique ?

Pas comme form action. Un formulaire qui envoie vers une action a besoin d'une page rendue à la demande via un adaptateur. Sur un site entièrement statique, envoyez plutôt le formulaire vers un backend de formulaires comme Ikiform.

### Comment obtenir l'IP du visiteur dans Astro ?

Utilisez context.clientAddress dans une Action ou Astro.clientAddress dans une page. L'adaptateur la remplit depuis la plateforme. Elle n'existe que sur les routes rendues à la demande.

### La CSP d'Astro prend-elle en charge Turnstile ?

Oui, avec security.csp, stable depuis Astro 6. Ajoutez https://challenges.cloudflare.com à scriptDirective.resources à côté de 'self', et ajoutez une directive frame-src pour le même hôte. Testez-la avec un build de production, car elle ne s'applique pas en dev.

### 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.
- [Sites statiques](https://ikiform.com/fr/guides/spam-protection/static-site.md): Arrêtez le spam d'un formulaire de site statique, même sans secret ni compteur, avec un partial honeypot, un script sûr et des vérifications côté serveur.
- [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.
