Frena el spam en formularios de contacto8 min de lectura
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.
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.
- El componente envía los campos y el token de Turnstile a /api/contact con $fetch.
- La server route lee la IP del cliente de la cabecera indicada en runtimeConfig.
- 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.
- Bajo el límite, verifica el token con siteverify, comprobando la action y el host.
- Luego reenvía los campos a Ikiform, que los guarda y puntúa el honeypot.
Configuración del proyecto
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" },
},
},
},
});- Cualquier clave de
runtimeConfigpuede sobrescribirse en tiempo de ejecución con una variableNUXT_:turnstileSecretKeyconNUXT_TURNSTILE_SECRET_KEY,public.turnstileSiteKeyconNUXT_PUBLIC_TURNSTILE_SITE_KEY. Las claves fuera depublicnunca llegan al navegador. routeRulesenví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 comunidadnuxt-securitypuede generar nonces si necesitas una política más estricta.- El driver
upstashsolo se monta en producción y leeUPSTASH_REDIS_REST_URLyUPSTASH_REDIS_REST_TOKENdel entorno. Necesita el paquete@upstash/redis, así que instálalo connpm install @upstash/redis.
Utilidades de servidor
Los archivos de server/utils se importan automáticamente en todas las server routes:
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;
}
}clientIplee exactamente una cabecera, la que sobrescribe tu host. PonNUXT_CLIENT_IP_HEADERenx-forwarded-foren Vercel,x-nf-client-connection-ipen Netlify ycf-connecting-ipen 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.
$fetchenvía el cuerpo a siteverify como JSON, que siteverify acepta. La comprobación del host usagetRequestHosty se omite en desarrollo, porque los tokens de prueba llevan un hostname ficticio.
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 };
});- El sufijo
.post.tshace que Nitro solo responda aPOST. Cualquier otra cosa recibe405. - 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 llevamessage, que el componente muestra. - El
Origindel visitante se reenvía, porque un fetch del lado del servidor no envía ninguno e Ikiform lo lee para reconocer las pruebas gratuitas desdelocalhost. - El honeypot
_gotchaviaja intacto, así que Ikiform guarda uno relleno como spam en lugar de descartarlo.
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.El composable y el formulario
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>- El composable solo se ejecuta en
onMounted, así que el renderizado en el servidor nunca tocawindow. - 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
.hpoculta el honeypot fuera de pantalla, como en la guía del honeypot.
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:
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"}'
doneLas 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 cubre el endpoint y las claves.
Comprueba que funciona
- Ejecuta
nuxt devcon las claves de prueba y envía un mensaje. Aparece en Submissions. Un espacio de trabajo sin plan acepta 50 pruebas desdelocalhostal mes. - Envía seis mensajes en un minuto. El sexto muestra cuántos segundos quedan.
- Pon
NUXT_TURNSTILE_SECRET_KEYcon el secreto que siempre falla y reinicia. Cada envío muestra el error de verificación humana. - Pide
/contactconcurl -Idespués denuxt build. La respuesta lleva tuContent-Security-Policy. - 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
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.
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.
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.
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.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.