# 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.

Publicado el 2026-10-25 · https://ikiform.com/es/guides/spam-protection/tanstack-start

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.

Diagrama: La cadena de middleware delante de la server function de TanStack Start

1. El navegador, o un script, envía a la URL de la server function.
2. El middleware CSRF de Start rechaza los envíos que no puede demostrar que vienen de tu propio origen.
3. El middleware de visitante lee la IP del cliente y la cabecera Origin y los pasa como contexto.
4. El validador normaliza la entrada antes de que la vea el handler.
5. El handler toma un token del bucket de esa IP. Si no quedan, devuelve un resultado de inténtalo más tarde.
6. Verifica el token de Turnstile y luego reenvía los campos a Ikiform, que los guarda y puntúa el honeypot.

Cada capa es más barata que la siguiente. Un envío rechazado nunca llega a siteverify ni a Ikiform.

## Configuración del proyecto

`Archivos`:

```text
src/
  routes/
    support.tsx
  lib/
    use-turnstile.ts
  server/
    bucket.ts
    visitor.ts
    support.ts
  start.ts
.env
```

`.env`:

```bash
VITE_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 con `import.meta.env`. Los secretos no tienen prefijo, así que solo existen en el servidor como `process.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

`src/server/visitor.ts`:

```ts
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:

`src/server/bucket.ts`:

```ts
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](https://ikiform.com/es/guides/spam-protection/rate-limiting.md), y mantén la misma forma `take(key)`.

## La server function

`src/server/support.ts`:

```ts
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.
- `action` debe ser `support`. Se rechaza un token emitido para otro widget con tu site key.
- El honeypot `_gotcha` se reenvía, no se comprueba aquí, así que uno relleno se guarda como spam donde puedes revisarlo.

> 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](https://ikiform.com/es/docs/rate-limits.md) 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.

## 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:

`src/lib/use-turnstile.ts`:

```ts
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 };
}
```

`src/routes/support.tsx`:

```tsx
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:

`src/start.ts`:

```ts
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-src` necesita `'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`, que `frame-src` debe permitir.
- El `filter` de 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:

`src/routes/support.tsx`:

```ts
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](https://ikiform.com/es/docs/guides/server.md) cubre las claves si mantienes la server function.

## Comprueba que funciona

1. Ejecuta el servidor de desarrollo con las claves de prueba y envía una petición. Aparece en **Submissions**. Sin plan, 50 pruebas desde `localhost` al mes son gratis.
2. Envía cuatro peticiones rápido. La cuarta devuelve el mensaje de inténtalo más tarde con un número de segundos.
3. Pon `TURNSTILE_SECRET_KEY` con el secreto que siempre falla. Cada petición muestra el error de verificación humana.
4. Repite la llamada a la server function desde la pestaña de red con `curl` y sin `Origin`. El middleware CSRF la rechaza.
5. 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

### ¿Cómo obtengo la IP del cliente en una server function de TanStack Start?

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.

### ¿Las server functions de TanStack Start tienen protección CSRF?

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.

### ¿Puedo limitar la frecuencia en memoria en TanStack Start?

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.

### ¿Ikiform es gratis?

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.

## Más guías

- [Frena el spam en formularios de contacto](https://ikiform.com/es/guides/spam-protection.md): Frena el spam en tu formulario de contacto con capas que no cuestan nada a las personas reales: honeypot, tiempo, puntuación del contenido, límites de frecuencia y verificación humana solo donde haga falta.
- [Límites de frecuencia](https://ikiform.com/es/guides/spam-protection/rate-limiting.md): Limita los envíos de un formulario por visitante con un almacén compartido, responde 429 con Retry-After y no bloquees oficinas que comparten una IP.
- [Cloudflare Turnstile](https://ikiform.com/es/guides/spam-protection/cloudflare-turnstile.md): Añade Cloudflare Turnstile a un formulario HTML básico: el widget, siteverify en el servidor, claves de prueba y una forma de usarlo sin claves ni servidor propios.
- [Sin captcha](https://ikiform.com/es/guides/spam-protection/without-captcha.md): Frena casi todo el spam de tus formularios sin ningún captcha: honeypot, hora de renderizado firmada, puntuación del contenido y límite de frecuencia, sumados en lugar de usarlos por separado.
- [Campo honeypot](https://ikiform.com/es/guides/spam-protection/honeypot.md): Añade a cualquier formulario un honeypot que atrape bots sin molestar a lectores de pantalla, teclado ni autocompletado, con la comprobación en el servidor para cada stack.
- [Alternativas a reCAPTCHA](https://ikiform.com/es/guides/spam-protection/recaptcha-alternatives.md): Compara alternativas a reCAPTCHA para formularios, de Turnstile y hCaptcha a proof-of-work o ningún captcha, y cambia con un ajuste pequeño y seguro.
