Frena el spam en formularios de contacto

Frena el spam en formularios de contacto9 min de lectura

Protección antispam para formularios de Next.js con Turnstile

Protege un formulario de Next.js del spam: un honeypot, Turnstile verificado en una server action, un límite por IP en Vercel, Netlify o Cloudflare, y su CSP.

Preet SutharFundador, Ikiform

En Next.js, comprueba el spam dentro de la server action, porque la action es un endpoint POST público que cualquiera puede llamar sin tu formulario. Limita la frecuencia por la IP de cliente que fija tu host, verifica el token de Turnstile con siteverify y luego reenvía los campos con el honeypot intacto. Carga el script de Turnstile con next/script y tu nonce de CSP, y permite challenges.cloudflare.com en frame-src.

Qué cambia en Next.js

Una server action parece una llamada a una función, pero Next.js la convierte en un endpoint POST con un ID en el JavaScript de la página. Un bot no necesita tu formulario. Puede repetir ese POST con los campos que quiera, tan rápido como quiera. Así que cada comprobación va dentro de la action, nunca solo en el componente.

Tres detalles de Next.js determinan el resto de esta página:

  • No existe request.ip. La IP del cliente viene de una cabecera, y de cuál puedes fiarte depende del host.
  • Un nonce implica renderizado dinámico. Un nonce por petición no puede aplicarse a una página generada en tiempo de build.
  • La navegación en el cliente mantiene viva la página. Un script de Turnstile cargado una vez no vuelve a renderizar un widget tras una navegación suave.
El orden de las comprobaciones dentro de la server action de Next.jsserveractionun POSTquecualquierapuedeenviarlímitedefrecuenciaregla deVercelFirewall,por IPsiteverifytoken,action,hostnameIkiformpuntúaelhoneypot,guardaestadodeerrorno sereenvíanadabajo el límitehumanosobre el límiteEl orden de las comprobaciones dentro de la server action de Next.jsserver actionun POST quecualquiera puedeenviarlímite defrecuenciaregla de VercelFirewall, por IPsiteverifytoken, action,hostnameIkiformpuntúa el honeypot,guardaestado deerrorno sereenvía nadabajo el límitehumanosobre el límite
  1. El formulario, o un script que lo repite, envía a la server action.
  2. La action comprueba el conteo del visitante con una regla de Vercel Firewall. Por encima del límite, devuelve un estado de error y se detiene.
  3. Envía el token de Turnstile y la IP del cliente a siteverify, y comprueba el nombre de la action y el hostname en la respuesta.
  4. Solo entonces reenvía los campos, honeypot incluido, a Ikiform, que puntúa y guarda el mensaje.
La comprobación más barata va primero. El honeypot viaja intacto, así que uno relleno se guarda como spam en lugar de descartarse en silencio.

Configuración del proyecto

Los archivos que esta página añade a un proyecto con App Router:

Archivos
app/
  contact/
    page.tsx
    contact-form.tsx
    actions.ts
lib/
  client-ip.ts
  turnstile.ts
proxy.ts
.env.local

Crea un widget de Turnstile en el panel de Cloudflare e indica cada hostname donde funciona el formulario. Mientras desarrollas, usa las claves de prueba de Cloudflare, que pasan en cualquier host, incluido localhost:

.env.local
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
TURNSTILE_HOSTNAME=
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-real-ip
  • Solo NEXT_PUBLIC_TURNSTILE_SITE_KEY llega al navegador. Next.js incrusta los valores NEXT_PUBLIC_ en tiempo de build, así que cambiar la site key requiere un build nuevo.
  • TURNSTILE_HOSTNAME se queda vacío en local, porque los tokens de prueba no llevan tu hostname. Ponle tu dominio real en producción.
  • Cambia el secreto por 2x0000000000000000000000000000000AA para ver fallar todas las comprobaciones.

Lee la IP de cliente que fija tu host

Un límite de frecuencia basado en una cabecera que el visitante puede escribir no es ningún límite. Cada host fija una cabecera que sobrescribe a la entrada, y esa es la única de la que fiarse:

Qué cabecera lleva la IP real del cliente en cada hostpetición entranteclientIp()lee CLIENT_IP_HEADERVercelx-real-ip ox-forwarded-forNetlifyx-nf-client-connection-ipCloudflarecf-connecting-ipQué cabecera lleva la IP real del cliente en cada hostpeticiónentranteclientIp()leeCLIENT_IP_HEADERVercelx-real-ip ox-forwarded-forNetlifyx-nf-client-connection-ipCloudflarecf-connecting-ip
  1. clientIp() lee la única cabecera indicada en CLIENT_IP_HEADER e ignora el resto.
  2. Vercel sobrescribe x-forwarded-for y x-real-ip con la dirección del visitante.
  3. Netlify pone la dirección en x-nf-client-connection-ip.
  4. Cloudflare, incluido Next.js desplegado con OpenNext, fija cf-connecting-ip.
Vercel documenta que sobrescribe X-Forwarded-For para evitar suplantaciones. La documentación de Netlify solo menciona context.ip en Functions; x-nf-client-connection-ip es la cabecera que señala el equipo de Netlify.
lib/client-ip.ts
import "server-only";
import { headers } from "next/headers";

const HEADER = process.env.CLIENT_IP_HEADER ?? "x-real-ip";

export async function clientIp() {
  const value = (await headers()).get(HEADER);
  return value?.split(",")[0]?.trim() || null;
}

Verifica Turnstile en un helper solo de servidor

lib/turnstile.ts
import "server-only";

interface Verdict {
  success: boolean;
  hostname?: string;
  action?: string;
  "error-codes"?: string[];
}

export type Check = { ok: true } | { ok: false; reason: string };

export async function verifyTurnstile(token: string, ip: string | null): Promise<Check> {
  if (!token || token.length > 2048) return { ok: false, reason: "missing-input-response" };

  let verdict: Verdict;
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: token, remoteip: ip ?? undefined }),
      signal: AbortSignal.timeout(5000),
    });
    verdict = (await response.json()) as Verdict;
  } catch {
    return { ok: false, reason: "siteverify-unreachable" };
  }

  if (!verdict.success) return { ok: false, reason: verdict["error-codes"]?.join(",") || "failed" };
  if (verdict.action !== "contact") return { ok: false, reason: "wrong-action" };
  const expected = process.env.TURNSTILE_HOSTNAME;
  if (expected && verdict.hostname !== expected) return { ok: false, reason: "wrong-hostname" };
  return { ok: true };
}
  • Un token es válido durante 300 segundos y se puede verificar una vez; después siteverify devuelve timeout-or-duplicate.
  • success por sí solo no basta. Un token emitido en otro sitio con tu site key también dice success, así que el helper comprueba action y, en producción, hostname.
  • Falla de forma segura. Si no se puede contactar con Cloudflare, el visitante ve un error y ningún bot pasa.

La server action

app/contact/actions.ts
"use server";

import { checkRateLimit } from "@vercel/firewall";
import { headers } from "next/headers";
import { clientIp } from "@/lib/client-ip";
import { verifyTurnstile } from "@/lib/turnstile";

export interface ContactState {
  status: "idle" | "sent" | "error";
  message: string;
  fields?: { name: string; email: string; message: string };
}

export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
  const fields = {
    name: String(formData.get("name") ?? "").trim(),
    email: String(formData.get("email") ?? "").trim(),
    message: String(formData.get("message") ?? "").trim(),
  };

  if (process.env.VERCEL) {
    const { rateLimited } = await checkRateLimit("contact-form", { headers: await headers() });
    if (rateLimited) return { status: "error", message: "Too many messages from your connection. Try again in a minute.", fields };
  }

  const check = await verifyTurnstile(String(formData.get("cf-turnstile-response") ?? ""), await clientIp());
  if (!check.ok) {
    console.warn("turnstile", check.reason);
    return { status: "error", message: "We couldn't confirm you're human. Try the check again.", fields };
  }

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      Origin: (await headers()).get("origin") ?? "",
    },
    body: JSON.stringify({ ...fields, _gotcha: String(formData.get("_gotcha") ?? "") }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { status: "sent", message: "Thanks. We'll reply by email." };
  return { status: "error", message: result?.error?.message ?? "Your message didn't send. Try again.", fields };
}

En Vercel, el límite viene del Firewall. Abre el Firewall de tu proyecto, añade una regla cuya condición sea @vercel/firewall, pon el Rate limit ID en contact-form y elige una ventana como 5 peticiones por minuto. checkRateLimit recibe las cabeceras de la petición, así que funciona dentro de una server action donde no hay objeto Request, y agrupa por IP de cliente salvo que pases un rateLimitKey.

  • Vercel cuenta cada regla por región, así que el tráfico repartido entre regiones puede superar el número que fijes. Para un formulario de contacto no pasa nada; para un conteo global exacto, usa Redis, como en la página de límites de frecuencia.
  • La cabecera Origin del visitante se reenvía porque un fetch del lado del servidor no envía ninguna, e Ikiform la lee para reconocer las pruebas gratuitas desde localhost.
  • Fuera de Vercel, la llamada al Firewall se omite. Pon ahí un limitador con Redis o Workers, con clientIp() como clave.
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 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 formulario y el widget

app/contact/contact-form.tsx
"use client";

import Script from "next/script";
import { useActionState, useEffect, useRef } from "react";
import { type ContactState, sendMessage } from "./actions";

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

declare global {
  interface Window {
    turnstile?: Turnstile;
  }
}

const initial: ContactState = { status: "idle", message: "" };

export function ContactForm({ nonce }: { nonce?: string }) {
  const [state, action, pending] = useActionState(sendMessage, initial);
  const box = useRef<HTMLDivElement>(null);
  const widget = useRef<string | null>(null);

  function mount() {
    if (!box.current || !window.turnstile || widget.current) return;
    widget.current = window.turnstile.render(box.current, {
      sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!,
      action: "contact",
    });
  }

  useEffect(() => {
    if (state.status === "error" && widget.current) window.turnstile?.reset(widget.current);
  }, [state]);

  useEffect(
    () => () => {
      if (widget.current) window.turnstile?.remove(widget.current);
      widget.current = null;
    },
    [],
  );

  if (state.status === "sent") return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} defaultValue={state.fields?.name} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.fields?.email} />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} defaultValue={state.fields?.message} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <div ref={box} />
      {state.status === "error" ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
      <Script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" nonce={nonce} onReady={mount} />
    </form>
  );
}
  • ?render=explicit evita que el script busque widgets en la página. onReady se ejecuta cuando carga el script y de nuevo cada vez que se monta el componente, lo que cubre la navegación en el cliente.
  • Un token sirve una vez. Cuando la action devuelve un error, el effect reinicia el widget para que el siguiente intento lleve un token nuevo.
  • El formulario también se envía antes de la hidratación, porque action es la propia server action. Sin JavaScript no hay widget, así que siteverify rechaza el envío; la verificación humana necesita un navegador.
  • La clase .hp de la guía del honeypot oculta el honeypot fuera de pantalla.
app/contact/page.tsx
import { headers } from "next/headers";
import { ContactForm } from "./contact-form";

export default async function Page() {
  const nonce = (await headers()).get("x-nonce") ?? undefined;

  return (
    <main>
      <h1>Contact us</h1>
      <ContactForm nonce={nonce} />
    </main>
  );
}

Una CSP que deja funcionar a Turnstile

Next.js lee el nonce de la cabecera de petición Content-Security-Policy y lo añade a sus propios scripts y a cualquier <Script> que reciba la prop nonce. Cloudflare indica que Turnstile pasa el nonce a lo que carga, y que su iframe necesita frame-src https://challenges.cloudflare.com. En Next.js 16 el archivo es proxy.ts, que sustituyó a middleware.ts:

proxy.ts
import { type NextRequest, NextResponse } from "next/server";

export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const dev = process.env.NODE_ENV === "development";
  const policy = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://challenges.cloudflare.com${dev ? " 'unsafe-eval'" : ""}`,
    "frame-src https://challenges.cloudflare.com",
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' blob: data:",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
  ].join("; ");

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);
  requestHeaders.set("Content-Security-Policy", policy);
  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("Content-Security-Policy", policy);
  return response;
}

export const config = {
  matcher: [
    {
      source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};
  • Con 'strict-dynamic', los navegadores modernos confían en el nonce e ignoran el host, que se mantiene para navegadores antiguos.
  • Leer headers() en la página hace que se renderice en cada petición, algo que un nonce requiere. Una página de contacto generada estáticamente no puede llevar uno; usa en su lugar una política sin nonces en headers() de next.config, a costa de 'unsafe-inline'.

Despliegue

HostCLIENT_IP_HEADERLímite de frecuenciaNotas
Vercelx-real-ipRegla de Firewall y checkRateLimitAñade una regla por cada entorno en el que pruebes
Netlifyx-nf-client-connection-ipRedis, como UpstashLas server actions se ejecutan en una sola función de Netlify
Cloudflare, con OpenNextcf-connecting-ipBinding de rate limiting de WorkersEl binding es aproximado y cuenta por ubicación

Pon TURNSTILE_HOSTNAME con tu dominio real en producción. Los despliegues de preview tienen sus propios hostnames, así que añádelos al widget o deja la comprobación desactivada en preview.

Deja que Ikiform haga las comprobaciones

Todo lo anterior existe porque la server action reenvía con una clave secreta, lo que desactiva la verificación humana propia de Ikiform y su límite por visitante. Envía desde el navegador y Ikiform ejecuta por ti el honeypot, Turnstile y el límite de frecuencia. Sin claves de Cloudflare, sin cambios en proxy.ts más allá de permitir https://ikiform.com en frame-src y connect-src, y sin action:

app/contact/contact-form.tsx
"use client";

import { challenge, submit } from "@ikiform/sdk";
import { useActionState, useRef } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

export function ContactForm() {
  const frame = useRef<HTMLDivElement>(null);
  const [message, action, pending] = useActionState(async (_previous: string, data: FormData) => {
    try {
      const turnstileToken = await challenge(FORM_ID, { container: frame.current ?? undefined });
      const result = await submit(FORM_ID, data, { turnstileToken });
      return result.ok ? "sent" : result.error.message;
    } catch {
      return "The human check didn't finish. Try again.";
    }
  }, "");

  if (message === "sent") return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <form action={action}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <div ref={frame} />
      {message ? <p role="alert">{message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}

Pon el Spam check del formulario en Ikiform y su Rate limit en Strict, 2 cada 10 minutos por visitante, y añade tu dominio más localhost en Allowed domains. challenge() monta un pequeño frame de ikiform.com que permanece invisible salvo que Turnstile necesite un clic. El spam recibe la misma respuesta de éxito que un mensaje real, se guarda con los motivos por los que se atrapó y se conserva 30 días antes de eliminarse. La guía de configuración de Next.js explica cómo crear el formulario y sus claves.

Comprueba que funciona

  1. Ejecuta next dev con las claves de prueba y envía un mensaje. Llega a Submissions. Sin plan, un espacio de trabajo acepta 50 envíos de prueba al mes desde localhost.
  2. Pon TURNSTILE_SECRET_KEY con el secreto que siempre falla y envía. El formulario muestra el error de verificación humana y conserva tu texto.
  3. En un preview de Vercel con la regla de Firewall, envía seis veces en un minuto. El sexto muestra el mensaje de límite.
  4. 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.
  5. Abre la página con la consola visible. No hay infracciones de CSP para challenges.cloudflare.com.

Trampas habituales

ProblemaQué hacer
El widget nunca aparece al navegar a la páginaEl script se cargó en una página anterior y no vuelve a escanear. Renderiza desde onReady, como en el ejemplo, no desde onLoad.
Todos los mensajes fallan la verificación humana en producciónTURNSTILE_HOSTNAME no coincide con el hostname que devuelve siteverify, a menudo por www. frente al dominio raíz. Registra check.reason para ver cuál es.
Todos los visitantes comparten un mismo bucket del límite de frecuenciaCLIENT_IP_HEADER indica una cabecera que tu host no fija, así que clientIp() devuelve null. Elige la cabecera de la tabla de arriba.
checkRateLimit devuelve error: "not-found"Ninguna regla de Firewall tiene el Rate limit ID contact-form en este entorno. Añádela y luego publica los cambios del Firewall.

Preguntas

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.