Acabe com o spam no formulário de contato9 min de leitura
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.
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.
- O navegador, ou um script, envia para a URL da server function.
- O middleware de CSRF do Start recusa envios que ele não consegue provar que vieram da sua própria origem.
- O middleware de visitante lê o IP do cliente e o cabeçalho Origin e os passa adiante como contexto.
- O validator normaliza o input antes de o handler vê-lo.
- O handler tira um token do bucket daquele IP. Sem nenhum sobrando, retorna um resultado de tente mais tarde.
- Ele valida o token do Turnstile e depois repassa os campos ao Ikiform, que os guarda e pontua o honeypot.
Configuração do projeto
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- O Vite só expõe ao navegador variáveis com o prefixo
VITE_, lidas porimport.meta.env. Os segredos não têm prefixo, então só existem no servidor, comoprocess.env. - As chaves de teste passam em qualquer host, inclusive
localhost. Troque pelas reais em cada ambiente.
Function middleware para o IP do 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 } });
});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:
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, e mantenha o mesmo formato take(key).
A 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." };
});- 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.
actionprecisa sersupport. 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.
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:
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>
);
}- 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
.urldelas, 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:
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-srcprecisa 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, queframe-srcprecisa permitir. - O
filterdo 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:
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 cobre as chaves se você mantiver a server function.
Confira se funciona
- 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
localhostsão gratuitos. - Envie quatro requisições rapidamente. A quarta retorna a mensagem de tente mais tarde com um número de segundos.
- Defina
TURNSTILE_SECRET_KEYcom o segredo que sempre falha. Toda requisição mostra o erro da verificação humana. - Repita a chamada da server function a partir da aba de rede com
curle semOrigin. O middleware de CSRF a recusa. - 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
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.
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.
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.
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.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.