Frena el spam en formularios de contacto8 min de lectura
Protección antispam para formularios de React Router con Turnstile
Protege un formulario de React Router del spam: una action de ruta con Turnstile, un límite en Workers basado en cf-connecting-ip, un módulo .server y una cabecera CSP.
Haz las comprobaciones en la action de la ruta: limita por la IP del cliente, verifica el token de Turnstile con siteverify y luego reenvía los campos con el honeypot. Mantén los helpers en un módulo .server.ts para que nunca lleguen al navegador, y fija la CSP desde el export headers de la ruta. En Cloudflare Workers, el binding de rate limiting y los secretos vienen de env en cloudflare:workers.
Qué cambia en React Router
En el modo framework, cada ruta con una action acepta POSTs, de tu formulario o de cualquier otro. React Router te da tres herramientas que determinan cómo encajan las comprobaciones antispam:
- Módulos
.server. Un archivo llamadospam.server.tsno puede empaquetarse en el código de cliente, así que los secretos y las llamadas a siteverify se quedan en el servidor aunque un componente importe desde la misma ruta. - El export
headers. Las cabeceras que devuelve una action no se envían en una respuesta de documento salvo que la funciónheadersde la ruta las pase. La CSP también vive ahí. useFetcher. Envía a la action sin navegación, así que la página y su widget siguen montados entre intentos. Por eso el widget se vuelve a montar con unakeydespués de cada respuesta.
Esta página despliega en Cloudflare Workers, un hogar habitual de React Router, donde hay un binding de rate limiting integrado. La tabla de despliegue cubre Vercel y Netlify.
- Una vez hidratado, el formulario envía mediante useFetcher, así que la página no navega.
- La action limita la frecuencia por IP, verifica el token de Turnstile y reenvía los campos.
- Su resultado llega como fetcher.data.
- La key del widget cambia, así que hay un widget nuevo, y un token nuevo, listo para el siguiente intento.
- Antes de la hidratación, el mismo formulario envía como una petición de documento normal, sin token de Turnstile.
- La action lo rechaza en siteverify.
- El export headers pasa Retry-After y la CSP a la respuesta del documento.
- El componente lee actionData en lugar de fetcher.data y muestra el error.
Configuración del proyecto
Parte de la plantilla de React Router de Cloudflare con @cloudflare/vite-plugin. En React Router 8 el argumento context siempre es un RouterContextProvider, y un objeto { cloudflare: { env } } sin más desde la entrada del Worker lanza un error, así que la entrada pasa solo la petición y la ruta lee los bindings con import { env } from "cloudflare:workers". Los archivos que toca esta página:
app/
routes/
contact.tsx
components/
turnstile.tsx
lib/
spam.server.ts
workers/
app.ts
wrangler.jsonc
.dev.varsimport { createRequestHandler } from "react-router";
const handler = createRequestHandler(() => import("virtual:react-router/server-build"), import.meta.env.MODE);
export default {
fetch(request) {
return handler(request);
},
} satisfies ExportedHandler<Env>;{
"name": "acme-site",
"main": "./workers/app.ts",
"vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
"ratelimits": [
{
"name": "CONTACT_LIMIT",
"namespace_id": "1001",
"simple": { "limit": 5, "period": 60 }
}
]
}TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key- La site key es pública, así que va en
vars. El loader se la pasa al componente, así que el build del cliente no necesita ninguna variable de entorno. - Los secretos van en
.dev.varsen local y conwrangler secret puten producción, nunca enwrangler.jsonc. - Ejecuta
wrangler typesdespués de editar la configuración, para queCONTACT_LIMITy los secretos tengan tipo enenv.
Los helpers solo de servidor
export async function isHuman(secret: string, token: string, ip: string, host: string) {
if (!token || token.length > 2048) return false;
const body = new URLSearchParams({ secret, 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 };
return verdict.success && verdict.action === "contact" && (import.meta.env.DEV || verdict.hostname === host);
} catch {
return false;
}
}
export async function forward(secretKey: string, form: FormData, origin: string | null) {
const headers = new Headers({ Accept: "application/json", Authorization: `Bearer ${secretKey}` });
if (origin) headers.set("Origin", origin);
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", { method: "POST", headers, body: form }).catch(() => null);
return response?.ok ?? false;
}- El sufijo
.serverhace que el build falle si el código de cliente fuera a incluir este módulo. - Siteverify acepta cuerpos form-encoded. La comprobación del hostname compara con el host al que envió el visitante y se omite en desarrollo, porque los tokens de prueba llevan un hostname ficticio.
forwardenvía elFormDataoriginal, así que todos los campos, incluido el honeypot, llegan a Ikiform tal como se enviaron.- También reenvía el
Origindel visitante, porque un fetch del lado del servidor no envía ninguno e Ikiform lo lee para reconocer las pruebas gratuitas desdelocalhost.
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.El widget
import { useEffect, useRef } from "react";
const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
interface TurnstileApi {
render(container: HTMLElement, options: { sitekey: string; action: string }): string;
remove(widgetId: string): void;
}
declare global {
interface Window {
turnstile?: TurnstileApi;
}
}
export function Turnstile({ siteKey }: { siteKey: string }) {
const box = useRef<HTMLDivElement>(null);
useEffect(() => {
let widgetId: string | undefined;
const mount = () => {
if (box.current && window.turnstile) widgetId = window.turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
};
if (window.turnstile) mount();
else document.querySelector(`script[src="${SCRIPT}"]`)?.addEventListener("load", mount, { once: true });
return () => {
if (widgetId) window.turnstile?.remove(widgetId);
};
}, [siteKey]);
return (
<>
<script src={SCRIPT} async />
<div ref={box} />
</>
);
}React 19 sube un <script> async al head y lo carga una sola vez, por muchas veces que se renderice el componente. El effect renderiza el widget de forma explícita, como recomienda Cloudflare para single-page apps, y lo elimina al desmontar.
La ruta
import { useEffect, useState } from "react";
import { data, useFetcher } from "react-router";
import { env } from "cloudflare:workers";
import { Turnstile } from "~/components/turnstile";
import { forward, isHuman } from "~/lib/spam.server";
import type { Route } from "./+types/contact";
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 function loader() {
return { siteKey: env.TURNSTILE_SITE_KEY };
}
export async function action({ request }: Route.ActionArgs) {
const ip = request.headers.get("cf-connecting-ip") ?? "unknown";
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
if (!success) {
return data({ ok: false, error: "Too many messages. Try again in a minute." }, { status: 429, headers: { "Retry-After": "60" } });
}
const form = await request.formData();
const token = String(form.get("cf-turnstile-response") ?? "");
if (!(await isHuman(env.TURNSTILE_SECRET_KEY, token, ip, new URL(request.url).hostname))) {
return data({ ok: false, error: "We couldn't confirm you're human. Try again." }, { status: 403 });
}
form.delete("cf-turnstile-response");
const sent = await forward(env.IKIFORM_SECRET_KEY, form, request.headers.get("origin"));
return sent ? { ok: true, error: "" } : data({ ok: false, error: "Your message didn't send. Try again." }, { status: 502 });
}
export function headers({ actionHeaders, parentHeaders }: Route.HeadersArgs) {
parentHeaders.set("Content-Security-Policy", POLICY);
const retry = actionHeaders.get("Retry-After");
if (retry) parentHeaders.set("Retry-After", retry);
return parentHeaders;
}
export default function Contact({ loaderData, actionData }: Route.ComponentProps) {
const fetcher = useFetcher<typeof action>();
const result = fetcher.data ?? actionData;
const [round, setRound] = useState(0);
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data) setRound((count) => count + 1);
}, [fetcher.state, fetcher.data]);
if (result?.ok) return <p role="status">Thanks. We'll reply by email.</p>;
return (
<fetcher.Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required maxLength={120} />
<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} />
<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>
<Turnstile key={round} siteKey={loaderData.siteKey} />
{result?.error ? <p role="alert">{result.error}</p> : null}
<button disabled={fetcher.state !== "idle"}>{fetcher.state === "idle" ? "Send message" : "Sending"}</button>
</fetcher.Form>
);
}- El límite se ejecuta antes de
request.formData(), así que un flood nunca cuesta leer un cuerpo ni una llamada a siteverify. - Cloudflare fija
cf-connecting-ipen cada petición a un Worker y el visitante no puede fijarla. data()fija el estado yRetry-After. Un fetcher lee el cuerpo directamente; el exportheaderses lo que lleva esas cabeceras a una respuesta de documento.resultrecurre aactionData, así que el error se muestra igualmente cuando el formulario se envió antes de la hidratación.- La clase
.hpoculta el honeypot fuera de pantalla, como en la guía del honeypot.
React Router renderiza scripts en línea para la hidratación, así que esta política mantiene 'unsafe-inline' en script-src. Para quitarlo, genera un nonce en entry.server.tsx y pásalo a <Scripts> y al script de Turnstile; Cloudflare indica que Turnstile pasa el nonce a lo que carga.
Pruébalo en local
La documentación de Cloudflare indica que su plugin de Vite ejecuta tu código de servidor en el runtime de Workers durante el desarrollo, así que env de cloudflare:workers, .dev.vars y el binding de rate limiting se comportan como lo harán en producción. En local ningún edge de Cloudflare fija cf-connecting-ip, así que todas las peticiones cuentan como unknown y comparten un mismo límite. Es lo esperado. Para ver la cabecera real, despliega un preview con wrangler versions upload y prueba desde dos redes.
Desplegar en otro lugar
| Host | IP del cliente | Limitador | Secretos |
|---|---|---|---|
| Cloudflare Workers | cf-connecting-ip | Binding de rate limiting | wrangler secret put |
| Vercel | x-forwarded-for, sobrescrita por Vercel | Redis | process.env |
| Netlify | x-nf-client-connection-ip | Redis | process.env |
Cloudflare documenta el binding como aproximado y contado por ubicación, lo que vale para un formulario de contacto. Fuera de Workers no existe el módulo cloudflare:workers, así que lee los secretos de process.env y cambia el binding por un contador en Redis como el de la página de límites de frecuencia.
Deja que Ikiform haga las comprobaciones
Si gestionar claves, un binding y una CSP para un solo formulario es más de lo que quieres, apunta un formulario básico a tu endpoint de Ikiform y elimina la action:
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<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 />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<button>Send message</button>
</form>Usa un <form> en minúscula, no el <Form> de React Router, para que el navegador envíe directamente a Ikiform. Pon Spam check 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. Elige el Rate limit Strict, 2 cada 10 minutos por visitante, e indica tu dominio en Allowed domains. El spam atrapado recibe la misma respuesta que un mensaje real y se conserva 30 días. La guía de configuración de servidor explica cómo mantener tu propia action.
Comprueba que funciona
- Ejecuta
npm run devy envía un mensaje con las claves de prueba. Aparece en Submissions. Un espacio de trabajo sin plan acepta 50 pruebas desdelocalhostal mes. - Envía un segundo mensaje sin recargar. Aparece un widget nuevo tras la primera respuesta y el segundo envío funciona.
- Desactiva JavaScript y envía. La página se recarga con el error de verificación humana.
- Tras desplegar, envía seis veces en un minuto. El sexto recibe
429conRetry-After: 60. - 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 |
|---|---|
Server-only module referenced by client | Un componente importa de spam.server.ts algo que renderiza. Solo loader y action pueden usarlo. |
| El segundo intento siempre falla en siteverify | El widget conservó su token usado. Asegúrate de que su key cambia después de cada respuesta del fetcher. |
Invalid context value provided to handleRequest | La entrada del Worker sigue pasando { cloudflare: { env, ctx } }, como hacían las plantillas de React Router 7. Llama al handler solo con la petición y lee los bindings de cloudflare:workers. |
Falta Retry-After en la respuesta sin JavaScript | Las cabeceras de la action se descartan salvo que el export headers las devuelva. Cópialas de actionHeaders como se muestra. |
Preguntas
Renderiza el widget en el componente de la ruta, lee cf-turnstile-response de los datos del formulario en la action y envíalo con tu clave secreta a siteverify. Continúa solo si la respuesta dice success con la action que pusiste en el widget.
Declara un binding de rate limiting en wrangler.jsonc y llama a limit con una clave construida a partir de la cabecera cf-connecting-ip al principio de la action. Devuelve un 429 con data() cuando falle.
En una petición de documento, React Router solo envía las cabeceras que devuelve el export headers de tu ruta. Lee ahí actionHeaders y copia lo que necesites, como Retry-After.
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.