Frena el spam en formularios de contacto9 min de lectura
Protección antispam para formularios de TanStack Start con Turnstile
Protege un formulario de TanStack Start del spam: middleware de función para la IP del cliente, un token bucket, Turnstile verificado en una server function y una CSP.
Añade un middleware de función que lea una vez la IP del cliente y el origen y los pase a cada server function como contexto. En la server function, valida la entrada, toma un token de un bucket por IP, verifica el token de Turnstile con siteverify y reenvía los campos con el honeypot. Conserva el middleware CSRF que instala Start, y vuelve a añadirlo tú si creas src/start.ts.
Qué cambia en TanStack Start
Una server function es un endpoint RPC del mismo origen. Start la protege de otros sitios con un middleware CSRF que comprueba Sec-Fetch-Site, Origin o Referer, y eso impide que una página ajena envíe a través del navegador de un visitante. No frena a un script, que puede enviar las cabeceras que quiera. Así que las comprobaciones antispam siguen yendo dentro de la función.
La pieza propia de Start para esto es el middleware. El middleware de función se ejecuta antes que el validador y el handler, puede leer las cabeceras de la petición y entrega valores al handler mediante context. Escribe la búsqueda de IP una vez y todos los formularios del sitio la tendrán.
- El navegador, o un script, envía a la URL de la server function.
- El middleware CSRF de Start rechaza los envíos que no puede demostrar que vienen de tu propio origen.
- El middleware de visitante lee la IP del cliente y la cabecera Origin y los pasa como contexto.
- El validador normaliza la entrada antes de que la vea el handler.
- El handler toma un token del bucket de esa IP. Si no quedan, devuelve un resultado de inténtalo más tarde.
- Verifica el token de Turnstile y luego reenvía los campos a Ikiform, que los guarda y puntúa el honeypot.
Configuración del proyecto
src/
routes/
support.tsx
lib/
use-turnstile.ts
server/
bucket.ts
visitor.ts
support.ts
start.ts
.envVITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-forwarded-for- Vite solo expone al navegador las variables con el prefijo
VITE_, que se leen conimport.meta.env. Los secretos no tienen prefijo, así que solo existen en el servidor comoprocess.env. - Las claves de prueba pasan en cualquier host, incluido
localhost. Sustitúyelas por las reales en cada entorno.
Middleware de función para la IP del cliente
import { createMiddleware } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
const HEADER = process.env.CLIENT_IP_HEADER ?? "x-forwarded-for";
export const visitor = createMiddleware({ type: "function" }).server(async ({ next }) => {
const ip = getRequestHeader(HEADER)?.split(",")[0]?.trim() || "unknown";
return next({ context: { ip, origin: getRequestHeader("origin") ?? null } });
});Pon en CLIENT_IP_HEADER la cabecera que sobrescribe tu host: x-forwarded-for en Vercel, x-nf-client-connection-ip en Netlify, que recomienda el equipo de Netlify aunque su documentación solo describe context.ip en Functions, y cf-connecting-ip en Cloudflare. En tu propio servidor Node detrás de nginx, usa la cabecera que pone nginx y asegúrate de que no se pueda llegar a la app sin pasar por él. El middleware también reenvía el Origin del visitante, porque el fetch del servidor a Ikiform no envía ninguno e Ikiform lo lee para reconocer las pruebas gratuitas desde localhost.
Un token bucket por IP
Una ventana fija permite a un script mandar un cupo completo al final de un minuto y otro al principio del siguiente. Un token bucket recarga un intento cada vez, así que quien corrige una errata espera segundos, no un minuto:
const CAPACITY = 3;
const REFILL_MS = 20_000;
const buckets = new Map<string, { tokens: number; at: number }>();
export function take(key: string, now = Date.now()) {
const saved = buckets.get(key) ?? { tokens: CAPACITY, at: now };
const tokens = Math.min(CAPACITY, saved.tokens + (now - saved.at) / REFILL_MS);
if (tokens < 1) {
buckets.set(key, { tokens, at: now });
return { ok: false, retryAfter: Math.ceil(((1 - tokens) * REFILL_MS) / 1000) };
}
if (buckets.size > 50_000) buckets.clear();
buckets.set(key, { tokens: tokens - 1, at: now });
return { ok: true, retryAfter: 0 };
}Este contador vive en memoria, lo que es exacto en un único servidor de larga duración, como node .output/server/index.mjs en un contenedor. En Vercel o Netlify cada instancia de función tiene su propio map, así que el límite real es desconocido. Ahí, lleva el bucket a Redis, como muestra la página de límites de frecuencia, y mantén la misma forma take(key).
La server function
import { createServerFn } from "@tanstack/react-start";
import { take } from "./bucket";
import { visitor } from "./visitor";
const TOPICS = ["billing", "bug", "account", "other"];
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export interface SupportInput {
email: string;
topic: string;
message: string;
_gotcha: string;
token: string;
}
export type SupportResult = { ok: true } | { ok: false; field?: "email" | "message"; message: string };
async function isHuman(token: string, ip: string) {
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 }),
});
const verdict = (await response.json()) as { success: boolean; action?: string };
return verdict.success && verdict.action === "support";
} catch {
return false;
}
}
export const sendSupportRequest = createServerFn({ method: "POST" })
.middleware([visitor])
.validator((input: SupportInput) => ({
email: String(input.email ?? "").trim(),
topic: TOPICS.includes(input.topic) ? input.topic : "other",
message: String(input.message ?? "").trim().slice(0, 5000),
_gotcha: String(input._gotcha ?? ""),
token: String(input.token ?? "").slice(0, 2048),
}))
.handler(async ({ data, context }): Promise<SupportResult> => {
if (!EMAIL.test(data.email)) return { ok: false, field: "email", message: "Enter an address we can reply to." };
if (data.message.length < 10) return { ok: false, field: "message", message: "Tell us a little more." };
const limit = take(context.ip);
if (!limit.ok) return { ok: false, message: `Too many requests. Try again in ${limit.retryAfter} seconds.` };
if (!(await isHuman(data.token, context.ip))) return { ok: false, message: "We couldn't confirm you're human." };
const { token: _token, ...fields } = data;
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}`,
...(context.origin ? { Origin: context.origin } : {}),
},
body: JSON.stringify(fields),
}).catch(() => null);
return response?.ok ? { ok: true } : { ok: false, message: "Your request didn't send. Try again." };
});- El validador se ejecuta antes que el handler y solo normaliza la entrada. Los errores de campo vuelven como un resultado que el formulario puede mostrar junto al input correspondiente.
- Primero las comprobaciones baratas: las del email y la longitud no cuestan nada, el bucket cuesta una búsqueda en un map y siteverify cuesta una llamada de red.
actiondebe sersupport. Se rechaza un token emitido para otro widget con tu site key.- El honeypot
_gotchase reenvía, no se comprueba aquí, así que uno relleno se guarda como spam donde puedes revisarlo.
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.La ruta y el widget
El widget se renderiza de forma explícita desde un pequeño hook. Una promesa a nivel de módulo carga el script una sola vez, aunque la ruta se monte varias veces durante la navegación en el cliente, y el token llega mediante el callback de Turnstile:
import { useCallback, useEffect, useRef, useState } from "react";
interface Api {
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<Api> | undefined;
function load() {
loading ??= new Promise<Api>((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?: Api }).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(sitekey: string, action: string) {
const ref = useRef<HTMLDivElement>(null);
const api = useRef<Api | null>(null);
const widget = useRef<string | null>(null);
const [token, setToken] = useState("");
useEffect(() => {
let live = true;
load().then((turnstile) => {
if (!live || !ref.current) return;
api.current = turnstile;
widget.current = turnstile.render(ref.current, {
sitekey,
action,
callback: setToken,
"expired-callback": () => setToken(""),
});
});
return () => {
live = false;
if (widget.current) api.current?.remove(widget.current);
widget.current = null;
};
}, [sitekey, action]);
const reset = useCallback(() => {
setToken("");
if (widget.current) api.current?.reset(widget.current);
}, []);
return { ref, token, reset };
}import { createFileRoute } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { useTurnstile } from "../lib/use-turnstile";
import { type SupportResult, sendSupportRequest } from "../server/support";
export const Route = createFileRoute("/support")({ component: Support });
function Support() {
const send = useServerFn(sendSupportRequest);
const turnstile = useTurnstile(import.meta.env.VITE_TURNSTILE_SITE_KEY, "support");
const [result, setResult] = useState<SupportResult | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
setPending(true);
try {
setResult(
await send({
data: {
email: String(form.get("email") ?? ""),
topic: String(form.get("topic") ?? ""),
message: String(form.get("message") ?? ""),
_gotcha: String(form.get("_gotcha") ?? ""),
token: turnstile.token,
},
}),
);
} catch {
setResult({ ok: false, message: "Couldn't reach the server. Try again." });
} finally {
setPending(false);
turnstile.reset();
}
}
if (result?.ok) return <p role="status">Got it. We'll reply by email within a day.</p>;
const error = result && !result.ok ? result : null;
return (
<form onSubmit={onSubmit} noValidate>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" aria-invalid={error?.field === "email"} />
<label htmlFor="topic">Topic</label>
<select id="topic" name="topic" defaultValue="other">
<option value="billing">Billing</option>
<option value="bug">Something is broken</option>
<option value="account">My account</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">What happened?</label>
<textarea id="message" name="message" rows={6} aria-invalid={error?.field === "message"} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" aria-hidden="true" className="hp" />
<div ref={turnstile.ref} />
{error ? <p role="alert">{error.message}</p> : null}
<button disabled={pending || !turnstile.token}>{pending ? "Sending" : "Send request"}</button>
</form>
);
}- El botón sigue desactivado hasta que Turnstile entrega un token, así que nadie envía uno vacío.
turnstile.reset()se ejecuta después de cada intento. Un token sirve una vez y dura 300 segundos, así que un reintento necesita uno nuevo.- Este formulario necesita JavaScript. Las server functions pueden recibir envíos de formulario normales mediante su
.url, pero el widget de Turnstile necesita un navegador de todos modos.
Cabeceras de seguridad en src/start.ts
Start no tiene una opción de CSP propia. Un middleware de petición global en src/start.ts puede fijarla en cada respuesta. Crear ese archivo desactiva el middleware CSRF que Start instala por defecto, así que vuelve a añadirlo primero:
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
import { setResponseHeaders } from "@tanstack/react-start/server";
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'",
"frame-ancestors 'none'",
].join("; ");
const securityHeaders = createMiddleware().server(async ({ next }) => {
setResponseHeaders(new Headers({ "Content-Security-Policy": policy }));
return next();
});
export const startInstance = createStart(() => ({
requestMiddleware: [createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }), securityHeaders],
}));- El HTML renderizado en el servidor por Start incluye scripts en línea. Sin una configuración de nonces,
script-srcnecesita'unsafe-inline'; aun así bloquea los scripts de cualquier host que no hayas indicado. - Turnstile dibuja su widget en un iframe de
challenges.cloudflare.com, queframe-srcdebe permitir. - El
filterde CSRF limita la comprobación a las server functions, así que las cargas de página normales no se ven afectadas.
Despliegue
| Host | CLIENT_IP_HEADER | Limitador |
|---|---|---|
| Tu propio servidor Node o contenedor | La que fije tu proxy | En memoria funciona |
| Vercel | x-forwarded-for | Redis |
| Netlify | x-nf-client-connection-ip | Redis |
| Cloudflare Workers | cf-connecting-ip | Binding de rate limiting de Workers |
Vercel documenta que sobrescribe x-forwarded-for con la dirección del visitante. Indica cada hostname de producción y de preview en el widget de Turnstile antes de desplegar.
Deja que Ikiform haga las comprobaciones
Si no quieres gestionar tú las claves de Turnstile y un limitador, quita la server function y envía desde el navegador. En onSubmit, sustituye la llamada a send por un challenge y un submit, donde box es un ref a un div vacío que sustituye al widget:
import { challenge, submit } from "@ikiform/sdk";
const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const turnstileToken = await challenge(FORM_ID, { container: box.current ?? undefined });
const result = await submit(FORM_ID, new FormData(event.currentTarget), { turnstileToken });Pon el Spam check del formulario en Ikiform, elige el Rate limit Strict de 2 cada 10 minutos por visitante y añade tu dominio en Allowed domains. Ikiform ejecuta entonces el honeypot, Turnstile en ikiform.com, el límite por IP y la puntuación del contenido, y conserva lo que atrapa durante 30 días. Tu CSP necesita https://ikiform.com en frame-src y connect-src en lugar de Cloudflare. La guía de configuración de servidor cubre las claves si mantienes la server function.
Comprueba que funciona
- Ejecuta el servidor de desarrollo con las claves de prueba y envía una petición. Aparece en Submissions. Sin plan, 50 pruebas desde
localhostal mes son gratis. - Envía cuatro peticiones rápido. La cuarta devuelve el mensaje de inténtalo más tarde con un número de segundos.
- Pon
TURNSTILE_SECRET_KEYcon el secreto que siempre falla. Cada petición muestra el error de verificación humana. - Repite la llamada a la server function desde la pestaña de red con
curly sinOrigin. El middleware CSRF la rechaza. - 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 envíos cross-site llegan a las server functions después de añadir src/start.ts | Un src/start.ts personalizado sustituye el middleware CSRF por defecto. Añade createCsrfMiddleware() a requestMiddleware, como se muestra. |
| Todos los visitantes comparten un mismo bucket | CLIENT_IP_HEADER indica una cabecera que tu host no fija, así que todas las IP son unknown. Usa la cabecera de la tabla de arriba. |
| El límite parece reiniciarse al azar en Vercel | Cada instancia de función tiene su propia memoria. Lleva el bucket a Redis. |
| El widget se renderiza dos veces en desarrollo | El Strict Mode de React monta los effects dos veces. El cleanup elimina el primer widget, así que en producción se ve uno. |
Preguntas
Lee la cabecera que fija tu host con getRequestHeader de @tanstack/react-start/server, idealmente una sola vez en un middleware de función que la pase al handler como contexto. Usa x-forwarded-for en Vercel, x-nf-client-connection-ip en Netlify y cf-connecting-ip en Cloudflare.
Sí. Start instala un middleware CSRF para las server functions salvo que definas src/start.ts, en cuyo caso añades tú createCsrfMiddleware. Bloquea los envíos cross-site desde navegadores, no los scripts, así que las comprobaciones antispam siguen haciendo falta.
En un único servidor Node de larga duración, sí. En hosts serverless cada instancia tiene su propia memoria, así que usa un almacén compartido como Redis o el binding de rate limiting de Cloudflare.
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.