# Proteção contra spam em formulários TanStack Start com Turnstile

> Proteja um formulário TanStack Start contra spam: function middleware para o IP do cliente, token bucket, Turnstile validado numa server function e uma CSP.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/spam-protection/tanstack-start

Adicione um function middleware que leia o IP do cliente e a origem uma vez e os passe a toda server function como contexto. Na server function, valide o input, tire um token de um bucket por IP, valide o token do Turnstile com o siteverify e repasse os campos com o honeypot. Mantenha o middleware de CSRF que o Start instala, e adicione-o de volta você mesmo se criar `src/start.ts`.

## O que muda no TanStack Start

Uma server function é um endpoint RPC de mesma origem. O Start a protege de outros sites com um middleware de CSRF que confere `Sec-Fetch-Site`, `Origin` ou `Referer`, e isso impede que uma página em outro lugar envie pelo navegador de um visitante. Não impede um script, que pode mandar os cabeçalhos que quiser. Então as verificações de spam continuam pertencendo à função.

O bloco de construção do próprio Start para isso é o middleware. O function middleware roda antes do validator e do handler, pode ler cabeçalhos da requisição e passa valores ao handler por `context`. Escreva a busca do IP uma vez e todo formulário do site a recebe.

Diagrama: A cadeia de middleware na frente da server function do TanStack Start

1. O navegador, ou um script, envia para a URL da server function.
2. O middleware de CSRF do Start recusa envios que ele não consegue provar que vieram da sua própria origem.
3. O middleware de visitante lê o IP do cliente e o cabeçalho Origin e os passa adiante como contexto.
4. O validator normaliza o input antes de o handler vê-lo.
5. O handler tira um token do bucket daquele IP. Sem nenhum sobrando, retorna um resultado de tente mais tarde.
6. Ele valida o token do Turnstile e depois repassa os campos ao Ikiform, que os guarda e pontua o honeypot.

Cada camada é mais barata que a seguinte. Um envio recusado nunca chega ao siteverify nem ao Ikiform.

## Configuração do projeto

`Arquivos`:

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

- O Vite só expõe ao navegador variáveis com o prefixo `VITE_`, lidas por `import.meta.env`. Os segredos não têm prefixo, então só existem no servidor, como `process.env`.
- As chaves de teste passam em qualquer host, inclusive `localhost`. Troque pelas reais em cada ambiente.

## Function middleware para o IP do 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 } });
});
```

Defina `CLIENT_IP_HEADER` com o cabeçalho que seu host sobrescreve: `x-forwarded-for` na Vercel, `x-nf-client-connection-ip` na Netlify, que a equipe da Netlify recomenda embora a documentação dela só descreva `context.ip` em Functions, e `cf-connecting-ip` na Cloudflare. No seu próprio servidor Node atrás do nginx, use o cabeçalho que o nginx define e garanta que a aplicação não possa ser acessada contornando-o. O middleware também repassa o `Origin` do visitante, porque o fetch do lado do servidor ao Ikiform não envia nenhum e o Ikiform o lê para reconhecer testes gratuitos em `localhost`.

## Um token bucket por IP

Uma janela fixa deixa um script mandar uma cota inteira no fim de um minuto e outra no começo do seguinte. Um token bucket recarrega uma tentativa por vez, então quem está corrigindo um erro de digitação espera segundos, não um 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 fica em memória, o que é exato num único servidor de longa duração, como `node .output/server/index.mjs` num contêiner. Na Vercel ou na Netlify, cada instância de função mantém seu próprio map, então o limite real é desconhecido. Nesses casos, mova o bucket para o Redis, como mostra a [página de limite de taxa](https://ikiform.com/pt-br/guides/spam-protection/rate-limiting.md), e mantenha o mesmo formato `take(key)`.

## A 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." };
  });
```

- O validator roda antes do handler e só normaliza o input. Os erros de campo voltam como um resultado que o formulário pode mostrar ao lado do campo certo.
- Verificações baratas primeiro: as de e-mail e de tamanho não custam nada, o bucket custa uma consulta ao map, o siteverify custa uma chamada de rede.
- `action` precisa ser `support`. Um token emitido para outro widget com a sua site key é recusado.
- O honeypot `_gotcha` é repassado, não conferido aqui, então um preenchido é guardado como spam, onde você pode revisá-lo.

> As versões do lado do servidor enviam com uma chave secreta, então o Ikiform pula **Allowed domains** e a verificação humana dele. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o [limite de taxa](https://ikiform.com/pt-br/docs/rate-limits.md) Relaxed ou Custom para um formulário enviado por um servidor e mantenha `IKIFORM_SECRET_KEY` em variáveis de ambiente só do servidor.

## A rota e o widget

O widget é renderizado explicitamente a partir de um pequeno hook. Uma promise no nível do módulo carrega o script uma vez, mesmo quando a rota é montada várias vezes durante a navegação no cliente, e o token chega pelo `callback` do 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>
  );
}
```

- O botão fica desativado até o Turnstile entregar um token, então ninguém envia um vazio.
- `turnstile.reset()` roda após cada tentativa. Um token funciona uma vez e dura 300 segundos, então uma nova tentativa precisa de um novo.
- Este formulário precisa de JavaScript. Server functions aceitam envios de formulário simples pela `.url` delas, mas o widget do Turnstile precisa de um navegador de qualquer forma.

## Cabeçalhos de segurança em src/start.ts

O Start não tem opção de CSP própria. Um request middleware global em `src/start.ts` pode definir uma em toda resposta. Criar esse arquivo desliga o middleware de CSRF que o Start instala por padrão, então adicione-o de volta primeiro:

`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],
}));
```

- O HTML renderizado no servidor pelo Start inclui scripts inline. Sem uma configuração de nonce, `script-src` precisa de `'unsafe-inline'`; ainda assim ele bloqueia scripts de qualquer host que você não listou.
- O Turnstile desenha o widget num iframe de `challenges.cloudflare.com`, que `frame-src` precisa permitir.
- O `filter` do CSRF limita a verificação às server functions, então carregamentos normais de página não são afetados.

## Deploy

| Host | CLIENT_IP_HEADER | Limitador |
| --- | --- | --- |
| Seu próprio servidor Node ou contêiner | O que seu proxy definir | Em memória funciona |
| Vercel | `x-forwarded-for` | Redis |
| Netlify | `x-nf-client-connection-ip` | Redis |
| Cloudflare Workers | `cf-connecting-ip` | Binding de rate limiting do Workers |

A Vercel documenta que sobrescreve `x-forwarded-for` com o endereço do visitante. Liste cada hostname de produção e de preview no widget do Turnstile antes do deploy.

## Deixe o Ikiform rodar as verificações

Se você não quer manter chaves do Turnstile e um limitador por conta própria, tire a server function e envie a partir do navegador. Em `onSubmit`, troque a chamada `send` por um challenge e um submit, em que `box` é um ref numa `div` vazia que substitui o 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 });
```

Defina o **Spam check** do formulário como **Ikiform**, escolha o **Rate limit** Strict de 2 a cada 10 minutos por visitante e adicione seu domínio em **Allowed domains**. O Ikiform então roda o honeypot, o Turnstile em ikiform.com, o limite por IP e a pontuação de conteúdo, e guarda o que barra por 30 dias. Sua CSP precisa de `https://ikiform.com` em `frame-src` e `connect-src` em vez da Cloudflare. O [guia de configuração de servidor](https://ikiform.com/pt-br/docs/guides/server.md) cobre as chaves se você mantiver a server function.

## Confira se funciona

1. Rode o servidor de desenvolvimento com as chaves de teste e envie uma requisição. Ela aparece em **Submissions**. Sem plano, 50 testes por mês a partir de `localhost` são gratuitos.
2. Envie quatro requisições rapidamente. A quarta retorna a mensagem de tente mais tarde com um número de segundos.
3. Defina `TURNSTILE_SECRET_KEY` com o segredo que sempre falha. Toda requisição mostra o erro da verificação humana.
4. Repita a chamada da server function a partir da aba de rede com `curl` e sem `Origin`. O middleware de CSRF a recusa.
5. Envie com o honeypot preenchido, `_gotcha=bot`. Ele ainda recebe uma resposta de sucesso, mas o envio é guardado como spam e não dispara e-mail nem webhook.

## Pegadinhas

| Problema | O que fazer |
| --- | --- |
| Envios de outros sites chegam às server functions depois de adicionar `src/start.ts` | Um `src/start.ts` personalizado substitui o middleware de CSRF padrão. Adicione `createCsrfMiddleware()` a `requestMiddleware`, como mostrado. |
| Todos os visitantes compartilham um único bucket | `CLIENT_IP_HEADER` indica um cabeçalho que seu host não define, então todo IP vira `unknown`. Use o cabeçalho da tabela acima. |
| O limite parece zerar aleatoriamente na Vercel | Cada instância de função tem sua própria memória. Mova o bucket para o Redis. |
| O widget é renderizado duas vezes em desenvolvimento | O Strict Mode do React monta os effects duas vezes. A limpeza remove o primeiro widget, então em produção aparece um só. |

## Perguntas

### Como obtenho o IP do cliente numa server function do TanStack Start?

Leia o cabeçalho que seu host define com getRequestHeader de @tanstack/react-start/server, de preferência uma vez num function middleware que o passe ao handler como contexto. Use x-forwarded-for na Vercel, x-nf-client-connection-ip na Netlify e cf-connecting-ip na Cloudflare.

### As server functions do TanStack Start têm proteção contra CSRF?

Sim. O Start instala um middleware de CSRF para as server functions, a menos que você defina src/start.ts, caso em que você mesmo adiciona o createCsrfMiddleware. Ele bloqueia envios de navegador vindos de outros sites, não scripts, então as verificações de spam continuam necessárias.

### Posso limitar a taxa do TanStack Start em memória?

Num único servidor Node de longa duração, sim. Em hosts serverless cada instância tem sua própria memória, então use um armazenamento compartilhado como o Redis ou o binding de rate limiting da Cloudflare.

### O Ikiform é gratuito?

Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.

## Mais guias

- [Acabe com o spam no formulário de contato](https://ikiform.com/pt-br/guides/spam-protection.md): Acabe com o spam no formulário de contato com camadas que não custam nada a pessoas reais: honeypot, tempo, pontuação de conteúdo, limites de taxa e verificação humana só quando precisar.
- [Limite de taxa](https://ikiform.com/pt-br/guides/spam-protection/rate-limiting.md): Limite a taxa de envios por visitante com um armazenamento compartilhado, responda 429 com Retry-After e evite bloquear escritórios que compartilham um IP.
- [Cloudflare Turnstile](https://ikiform.com/pt-br/guides/spam-protection/cloudflare-turnstile.md): Adicione o Cloudflare Turnstile a um formulário HTML simples: o widget, o siteverify no servidor, chaves de teste e um jeito de rodá-lo sem chaves nem servidor próprio.
- [Sem captcha](https://ikiform.com/pt-br/guides/spam-protection/without-captcha.md): Barre a maior parte do spam em formulários sem captcha nenhum: honeypot, horário de renderização assinado, pontuação de conteúdo e limite de taxa, somados em vez de usados sozinhos.
- [Campo honeypot](https://ikiform.com/pt-br/guides/spam-protection/honeypot.md): Adicione a qualquer formulário um honeypot que pega bots sem atrapalhar leitores de tela, teclados ou preenchimento automático, com a verificação no servidor para cada stack.
- [Alternativas ao reCAPTCHA](https://ikiform.com/pt-br/guides/spam-protection/recaptcha-alternatives.md): Compare alternativas ao reCAPTCHA para formulários, do Turnstile e hCaptcha a prova de trabalho e nenhum captcha, e migre com uma mudança pequena e segura.
