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.
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 formulario, o un script que lo repite, envía a la server action.
- 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.
- 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.
- Solo entonces reenvía los campos, honeypot incluido, a Ikiform, que puntúa y guarda el mensaje.
Configuración del proyecto
Los archivos que esta página añade a un proyecto con App Router:
app/
contact/
page.tsx
contact-form.tsx
actions.ts
lib/
client-ip.ts
turnstile.ts
proxy.ts
.env.localCrea 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:
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_KEYllega al navegador. Next.js incrusta los valoresNEXT_PUBLIC_en tiempo de build, así que cambiar la site key requiere un build nuevo. TURNSTILE_HOSTNAMEse 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
2x0000000000000000000000000000000AApara 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:
- clientIp() lee la única cabecera indicada en CLIENT_IP_HEADER e ignora el resto.
- Vercel sobrescribe x-forwarded-for y x-real-ip con la dirección del visitante.
- Netlify pone la dirección en x-nf-client-connection-ip.
- Cloudflare, incluido Next.js desplegado con OpenNext, fija cf-connecting-ip.
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
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. successpor sí solo no basta. Un token emitido en otro sitio con tu site key también dicesuccess, así que el helper compruebaactiony, 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
"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
Origindel visitante se reenvía porque un fetch del lado del servidor no envía ninguna, e Ikiform la lee para reconocer las pruebas gratuitas desdelocalhost. - Fuera de Vercel, la llamada al Firewall se omite. Pon ahí un limitador con Redis o Workers, con
clientIp()como clave.
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.El formulario y el widget
"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=explicitevita que el script busque widgets en la página.onReadyse 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
actiones 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
.hpde la guía del honeypot oculta el honeypot fuera de pantalla.
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:
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 enheaders()denext.config, a costa de'unsafe-inline'.
Despliegue
| Host | CLIENT_IP_HEADER | Límite de frecuencia | Notas |
|---|---|---|---|
| Vercel | x-real-ip | Regla de Firewall y checkRateLimit | Añade una regla por cada entorno en el que pruebes |
| Netlify | x-nf-client-connection-ip | Redis, como Upstash | Las server actions se ejecutan en una sola función de Netlify |
| Cloudflare, con OpenNext | cf-connecting-ip | Binding de rate limiting de Workers | El 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:
"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
- Ejecuta
next devcon 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 desdelocalhost. - Pon
TURNSTILE_SECRET_KEYcon el secreto que siempre falla y envía. El formulario muestra el error de verificación humana y conserva tu texto. - 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.
- 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. - Abre la página con la consola visible. No hay infracciones de CSP para
challenges.cloudflare.com.
Trampas habituales
| Problema | Qué hacer |
|---|---|
| El widget nunca aparece al navegar a la página | El 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ón | TURNSTILE_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 frecuencia | CLIENT_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
Renderiza el widget en un componente de cliente, carga el script con next/script y lee el campo cf-turnstile-response en la server action. Envíalo con tu clave secreta a siteverify y continúa solo cuando la respuesta diga success con la action y el hostname que esperas.
Léela de la cabecera que fija tu host con headers() de next/headers: x-real-ip o x-forwarded-for en Vercel, x-nf-client-connection-ip en Netlify, cf-connecting-ip en Cloudflare. Next.js no tiene request.ip en el App Router.
Sí. Un nonce se crea en cada petición, así que la página debe renderizarse en cada petición. Mantén la página de contacto dinámica, o usa una política sin nonces que permita challenges.cloudflare.com por 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.