# Protección antispam para formularios de Nuxt con Turnstile

> Protege un formulario de Nuxt del spam: una server route de Nitro con Turnstile, un límite por IP en Nitro storage, secretos en runtimeConfig y una CSP en routeRules.

Publicado el 2026-10-25 · https://ikiform.com/es/guides/spam-protection/nuxt

Envía el formulario con `$fetch` a una server route que cuenta los intentos por IP de cliente en Nitro storage, verifica el token de Turnstile con siteverify y reenvía los campos con el honeypot. Guarda los secretos en el `runtimeConfig` privado, definidos desde variables de entorno `NUXT_` en tiempo de ejecución. Envía la CSP de la página de contacto desde `routeRules`, permitiendo `challenges.cloudflare.com` para el script y su frame.

## Qué cambia en Nuxt

El servidor de Nuxt es Nitro, y Nitro aporta dos cosas que necesita una comprobación antispam. Las server routes de `server/api` son endpoints sencillos con `$fetch` ya conectado a ellos, y `useStorage()` te da un almacén clave-valor cuyo backend eliges por entorno: memoria en desarrollo, Redis u otro driver en producción. Eso convierte el límite de frecuencia en un cambio de configuración en lugar de una dependencia.

Dos costumbres de Nuxt causan problemas aquí. `getRequestIP(event, { xForwardedFor: true })` devuelve la primera entrada de `X-Forwarded-For`, que la propia documentación de h3 considera entrada del cliente. Y los archivos `.env` no se leen en producción, así que los secretos deben definirse como variables de entorno de la plataforma.

Diagrama: Un envío de contacto a través de la server route de Nuxt y Nitro storage

1. El componente envía los campos y el token de Turnstile a /api/contact con $fetch.
2. La server route lee la IP del cliente de la cabecera indicada en runtimeConfig.
3. Cuenta los envíos de esa IP en el minuto actual en Nitro storage. Por encima de cinco, lanza un 429 con los segundos que quedan.
4. Bajo el límite, verifica el token con siteverify, comprobando la action y el host.
5. Luego reenvía los campos a Ikiform, que los guarda y puntúa el honeypot.

En desarrollo el almacenamiento está montado en memoria. En producción el mismo código habla con Redis, porque nuxt.config monta otro driver.

## Configuración del proyecto

`Archivos`:

```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" },
      },
    },
  },
});
```

- Cualquier clave de `runtimeConfig` puede sobrescribirse en tiempo de ejecución con una variable `NUXT_`: `turnstileSecretKey` con `NUXT_TURNSTILE_SECRET_KEY`, `public.turnstileSiteKey` con `NUXT_PUBLIC_TURNSTILE_SITE_KEY`. Las claves fuera de `public` nunca llegan al navegador.
- `routeRules` envía la CSP solo en `/contact`. Nuxt incrusta en línea su payload y sus scripts de hidratación, así que esta política mantiene `'unsafe-inline'`; el módulo de la comunidad `nuxt-security` puede generar nonces si necesitas una política más estricta.
- El driver `upstash` solo se monta en producción y lee `UPSTASH_REDIS_REST_URL` y `UPSTASH_REDIS_REST_TOKEN` del entorno. Necesita el paquete `@upstash/redis`, así que instálalo con `npm install @upstash/redis`.

## Utilidades de servidor

Los archivos de `server/utils` se importan automáticamente en todas las server routes:

`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` lee exactamente una cabecera, la que sobrescribe tu host. Pon `NUXT_CLIENT_IP_HEADER` en `x-forwarded-for` en Vercel, `x-nf-client-connection-ip` en Netlify y `cf-connecting-ip` en Cloudflare.
- El contador es una ventana fija en Nitro storage. Leer y luego escribir no es atómico, así que dos envíos en el mismo instante pueden colarse ambos bajo el límite; para un formulario de contacto es aceptable. Las claves no caducan solas, así que dale a la base de datos de Redis una política de expulsión.
- `$fetch` envía el cuerpo a siteverify como JSON, que siteverify acepta. La comprobación del host usa `getRequestHost` y se omite en desarrollo, porque los tokens de prueba llevan un hostname ficticio.

## 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 };
});
```

- El sufijo `.post.ts` hace que Nitro solo responda a `POST`. Cualquier otra cosa recibe `405`.
- El límite se ejecuta antes de `readBody`, así que un flood nunca cuesta leer un cuerpo ni una llamada a siteverify.
- Cada fallo lanza `createError`. El cuerpo JSON lleva `message`, que el componente muestra.
- El `Origin` del visitante se reenvía, porque un fetch del lado del servidor no envía ninguno e Ikiform lo lee para reconocer las pruebas gratuitas desde `localhost`.
- El honeypot `_gotcha` viaja intacto, así que Ikiform guarda uno relleno como spam en lugar de descartarlo.

> Las versiones de servidor envían con una clave secreta, así que Ikiform se salta **Allowed domains** y su verificación humana. Todos los envíos llegan entonces desde la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el [límite de frecuencia](https://ikiform.com/es/docs/rate-limits.md) Relaxed o Custom para un formulario que envías a través de un servidor, y guarda `IKIFORM_SECRET_KEY` en variables de entorno solo de servidor.

## El composable y el formulario

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

- El composable solo se ejecuta en `onMounted`, así que el renderizado en el servidor nunca toca `window`.
- El botón espera a tener un token, y `reset()` pide uno nuevo después de cada fallo, porque un token sirve una vez y dura 300 segundos.
- Este formulario necesita JavaScript. Para una versión que envía sin él, consulta la sección siguiente.
- La clase `.hp` oculta el honeypot fuera de pantalla, como en la [guía del honeypot](https://ikiform.com/es/guides/spam-protection/honeypot.md).

## Pruébalo en local

En desarrollo el montaje `ratelimit` está en memoria, así que el conteo se reinicia cuando se reinicia `nuxt dev`. Puedes ver funcionar el límite sin navegador. Los envíos sin token fallan en siteverify con `403`, y cada uno cuenta igualmente:

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

Las cinco primeras líneas imprimen `403` y la sexta imprime `429`. En local ningún proxy fija la cabecera de IP, así que todas las peticiones cuentan como `unknown`; es lo esperado, y es la razón de que la cabecera viva en la configuración y no en el código.

## Despliegue

| Host | NUXT_CLIENT_IP_HEADER | Driver de almacenamiento |
| --- | --- | --- |
| Vercel | `x-forwarded-for` | `upstash`, desde el Vercel Marketplace |
| Netlify | `x-nf-client-connection-ip` | `upstash` o `redis` |
| Cloudflare | `cf-connecting-ip` | `upstash`; Workers KV tiene consistencia eventual, así que los conteos van con retraso |
| Servidor Node | La cabecera que fija tu proxy | `redis`, o memoria en una sola instancia |

Define todas las variables `NUXT_` en el panel del host. Nuxt las lee al arrancar el servidor, así que cambiar una clave requiere un reinicio, no un nuevo build. Añade cada hostname de producción y de preview al widget de Turnstile.

## Deja que Ikiform haga las comprobaciones

Sin la server route, la página de contacto puede prerenderizarse con el resto de un sitio `nuxt generate`. Apunta un formulario básico a tu endpoint de Ikiform con `method="post"` y `action` con la URL de tu endpoint, y elimina `server/api/contact.post.ts` y el composable.

Pon el **Spam check** del formulario en **Ikiform**. Un envío sin token recibe una breve página de verificación en ikiform.com que ejecuta Turnstile y reenvía los campos, así que tu CSP no necesita entradas de Cloudflare. Elige el **Rate limit** Strict, 2 cada 10 minutos por visitante, e indica tu dominio en **Allowed domains**. Ikiform conserva el spam atrapado durante 30 días con los motivos por los que se atrapó. La [guía de configuración de Nuxt](https://ikiform.com/es/docs/guides/nuxt.md) cubre el endpoint y las claves.

## Comprueba que funciona

1. Ejecuta `nuxt dev` con las claves de prueba y envía un mensaje. Aparece en **Submissions**. Un espacio de trabajo sin plan acepta 50 pruebas desde `localhost` al mes.
2. Envía seis mensajes en un minuto. El sexto muestra cuántos segundos quedan.
3. Pon `NUXT_TURNSTILE_SECRET_KEY` con el secreto que siempre falla y reinicia. Cada envío muestra el error de verificación humana.
4. Pide `/contact` con `curl -I` después de `nuxt build`. La respuesta lleva tu `Content-Security-Policy`.
5. Envía con el honeypot relleno, `_gotcha=bot`. Sigue recibiendo una respuesta de éxito, pero el envío se guarda como spam y no envía email ni webhook.

## Trampas habituales

| Problema | Qué hacer |
| --- | --- |
| Los secretos están vacíos en producción | `.env` no se lee en tiempo de ejecución en producción. Define `NUXT_TURNSTILE_SECRET_KEY` y las demás en el entorno del host. |
| El límite se reinicia en cada despliegue o instancia | El almacenamiento sigue montado en memoria. Comprueba que `$production.nitro.storage` monta un driver compartido y que sus credenciales están definidas. |
| Un script supera el límite cambiando cabeceras | Lees `getRequestIP` con `xForwardedFor: true` en un host que no sobrescribe esa cabecera. Lee la cabecera propia del host con `clientIp`. |
| `window is not defined` durante el renderizado en el servidor | El código de Turnstile se ejecutó fuera de `onMounted`. Mantén todo acceso a `window` dentro de los hooks de ciclo de vida del composable. |

## Preguntas

### ¿Cómo añado Turnstile a un formulario de Nuxt?

Renderiza el widget de forma explícita en onMounted, guarda su token en un ref y envíalo con los campos a una server route. La ruta lo manda con tu clave secreta de runtimeConfig a siteverify y solo continúa si hay éxito.

### ¿Cómo limito la frecuencia de una server route de Nuxt?

Cuenta las peticiones por IP de cliente con useStorage de Nitro, montado en Redis o Upstash en producción para que todas las instancias compartan el conteo. Lanza un 429 con createError cuando el conteo supere tu límite.

### ¿getRequestIP es seguro para limitar la frecuencia en Nuxt?

No con xForwardedFor true en un host que deja pasar el X-Forwarded-For del propio cliente, porque la primera entrada es lo que haya enviado el cliente. Lee en su lugar la cabecera que sobrescribe tu host.

### ¿Ikiform es gratis?

No, no hay plan gratuito. Probar es gratis: puedes enviar 50 envíos de prueba al mes desde localhost antes de pagar. Para pasar a producción, Pro empieza en 6 $/mes por 500 envíos, o puedes pagar una sola vez 49 $ por 10.000 envíos o 149 $ por 50.000 envíos, con créditos que nunca caducan.

## Más guías

- [Frena el spam en formularios de contacto](https://ikiform.com/es/guides/spam-protection.md): Frena el spam en tu formulario de contacto con capas que no cuestan nada a las personas reales: honeypot, tiempo, puntuación del contenido, límites de frecuencia y verificación humana solo donde haga falta.
- [Cloudflare Turnstile](https://ikiform.com/es/guides/spam-protection/cloudflare-turnstile.md): Añade Cloudflare Turnstile a un formulario HTML básico: el widget, siteverify en el servidor, claves de prueba y una forma de usarlo sin claves ni servidor propios.
- [Límites de frecuencia](https://ikiform.com/es/guides/spam-protection/rate-limiting.md): Limita los envíos de un formulario por visitante con un almacén compartido, responde 429 con Retry-After y no bloquees oficinas que comparten una IP.
- [Campo honeypot](https://ikiform.com/es/guides/spam-protection/honeypot.md): Añade a cualquier formulario un honeypot que atrape bots sin molestar a lectores de pantalla, teclado ni autocompletado, con la comprobación en el servidor para cada stack.
- [Sin captcha](https://ikiform.com/es/guides/spam-protection/without-captcha.md): Frena casi todo el spam de tus formularios sin ningún captcha: honeypot, hora de renderizado firmada, puntuación del contenido y límite de frecuencia, sumados en lugar de usarlos por separado.
- [Alternativas a reCAPTCHA](https://ikiform.com/es/guides/spam-protection/recaptcha-alternatives.md): Compara alternativas a reCAPTCHA para formularios, de Turnstile y hCaptcha a proof-of-work o ningún captcha, y cambia con un ajuste pequeño y seguro.
