Acabe com o spam no formulário de contato

Acabe com o spam no formulário de contato9 min de leitura

Proteção contra spam em formulários Next.js com Turnstile

Proteja um formulário Next.js contra spam: honeypot, Turnstile validado numa server action, limite por IP na Vercel, Netlify ou Cloudflare, e uma CSP para isso.

Preet SutharFundador, Ikiform

No Next.js, verifique o spam dentro da server action, porque a action é um endpoint POST público que qualquer um pode chamar sem o seu formulário. Limite a taxa pelo IP do cliente que seu host define, valide o token do Turnstile com o siteverify e depois repasse os campos com o honeypot intacto. Carregue o script do Turnstile via next/script com o nonce da sua CSP e permita challenges.cloudflare.com em frame-src.

O que muda no Next.js

Uma server action parece uma chamada de função, mas o Next.js a transforma num endpoint POST com um ID no JavaScript da página. Um bot não precisa do seu formulário. Ele pode repetir esse POST com os campos que quiser, na velocidade que quiser. Então toda verificação pertence à action, nunca só ao componente.

Três detalhes do Next.js moldam o resto desta página:

  • Não existe request.ip. O IP do cliente vem de um cabeçalho, e em qual dá para confiar depende do host.
  • Nonce significa renderização dinâmica. Um nonce por requisição não se aplica a uma página gerada em tempo de build.
  • A navegação no cliente mantém a página viva. Um script do Turnstile carregado uma vez não renderiza o widget de novo após uma navegação suave.
A ordem em que as verificações rodam dentro da server action do Next.jsserveractionum POSTquequalquerum envialimitedetaxaregra doVercelFirewall,por IPsiteverifytoken,action,hostnameIkiformpontua ohoneypot,guardaestadodeerronadarepassadodentro do limitehumanoacima do limiteA ordem em que as verificações rodam dentro da server action do Next.jsserver actionum POST que qualquerum envialimite de taxaregra do VercelFirewall, por IPsiteverifytoken, action,hostnameIkiformpontua o honeypot,guardaestado deerronadarepassadodentro do limitehumanoacima do limite
  1. O formulário, ou um script que o repete, envia à server action.
  2. A action confere a contagem do visitante contra uma regra do Vercel Firewall. Acima do limite, ela retorna um estado de erro e para.
  3. Ela envia o token do Turnstile e o IP do cliente ao siteverify e confere o nome da action e o hostname na resposta.
  4. Só então ela repassa os campos, honeypot incluído, ao Ikiform, que pontua e guarda a mensagem.
A verificação mais barata roda primeiro. O honeypot segue intacto, então um preenchido é guardado como spam em vez de descartado em silêncio.

Configuração do projeto

Os arquivos que esta página adiciona a um projeto App Router:

Arquivos
app/
  contact/
    page.tsx
    contact-form.tsx
    actions.ts
lib/
  client-ip.ts
  turnstile.ts
proxy.ts
.env.local

Crie um widget do Turnstile no painel da Cloudflare e liste todo hostname onde o formulário roda. Enquanto desenvolve, use as chaves de teste da Cloudflare, que passam em qualquer host, inclusive localhost:

.env.local
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
TURNSTILE_HOSTNAME=
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-real-ip
  • Só NEXT_PUBLIC_TURNSTILE_SITE_KEY chega ao navegador. O Next.js embute valores NEXT_PUBLIC_ em tempo de build, então mudar a site key exige um novo build.
  • TURNSTILE_HOSTNAME fica vazio localmente, porque tokens de teste não trazem seu hostname. Defina-o com seu domínio real em produção.
  • Troque o segredo por 2x0000000000000000000000000000000AA para ver todas as verificações falharem.

Leia o IP do cliente que seu host define

Um limite de taxa baseado num cabeçalho que o visitante pode escrever não é limite nenhum. Cada host define um cabeçalho que ele sobrescreve na entrada, e esse é o único em que se deve confiar:

Qual cabeçalho traz o IP real do cliente em cada hostrequisição recebidaclientIp()lê CLIENT_IP_HEADERVercelx-real-ip oux-forwarded-forNetlifyx-nf-client-connection-ipCloudflarecf-connecting-ipQual cabeçalho traz o IP real do cliente em cada hostrequisiçãorecebidaclientIp()lêCLIENT_IP_HEADERVercelx-real-ip oux-forwarded-forNetlifyx-nf-client-connection-ipCloudflarecf-connecting-ip
  1. clientIp() lê o único cabeçalho indicado em CLIENT_IP_HEADER e ignora o resto.
  2. A Vercel sobrescreve x-forwarded-for e x-real-ip com o endereço do visitante.
  3. A Netlify coloca o endereço em x-nf-client-connection-ip.
  4. A Cloudflare, inclusive com Next.js publicado via OpenNext, define cf-connecting-ip.
A Vercel documenta que sobrescreve X-Forwarded-For para evitar falsificação. A documentação da Netlify só cita context.ip em Functions; x-nf-client-connection-ip é o cabeçalho que a equipe da Netlify indica.
lib/client-ip.ts
import "server-only";
import { headers } from "next/headers";

const HEADER = process.env.CLIENT_IP_HEADER ?? "x-real-ip";

export async function clientIp() {
  const value = (await headers()).get(HEADER);
  return value?.split(",")[0]?.trim() || null;
}

Valide o Turnstile num helper só do servidor

lib/turnstile.ts
import "server-only";

interface Verdict {
  success: boolean;
  hostname?: string;
  action?: string;
  "error-codes"?: string[];
}

export type Check = { ok: true } | { ok: false; reason: string };

export async function verifyTurnstile(token: string, ip: string | null): Promise<Check> {
  if (!token || token.length > 2048) return { ok: false, reason: "missing-input-response" };

  let verdict: Verdict;
  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 ?? undefined }),
      signal: AbortSignal.timeout(5000),
    });
    verdict = (await response.json()) as Verdict;
  } catch {
    return { ok: false, reason: "siteverify-unreachable" };
  }

  if (!verdict.success) return { ok: false, reason: verdict["error-codes"]?.join(",") || "failed" };
  if (verdict.action !== "contact") return { ok: false, reason: "wrong-action" };
  const expected = process.env.TURNSTILE_HOSTNAME;
  if (expected && verdict.hostname !== expected) return { ok: false, reason: "wrong-hostname" };
  return { ok: true };
}
  • Um token vale por 300 segundos e só pode ser validado uma vez; depois o siteverify retorna timeout-or-duplicate.
  • success sozinho não basta. Um token emitido em outro site com a sua site key também diz success, então o helper confere action e, em produção, hostname.
  • Ele falha de forma fechada. Se a Cloudflare estiver fora do ar, o visitante vê um erro e nenhum bot passa.

A server action

app/contact/actions.ts
"use server";

import { checkRateLimit } from "@vercel/firewall";
import { headers } from "next/headers";
import { clientIp } from "@/lib/client-ip";
import { verifyTurnstile } from "@/lib/turnstile";

export interface ContactState {
  status: "idle" | "sent" | "error";
  message: string;
  fields?: { name: string; email: string; message: string };
}

export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
  const fields = {
    name: String(formData.get("name") ?? "").trim(),
    email: String(formData.get("email") ?? "").trim(),
    message: String(formData.get("message") ?? "").trim(),
  };

  if (process.env.VERCEL) {
    const { rateLimited } = await checkRateLimit("contact-form", { headers: await headers() });
    if (rateLimited) return { status: "error", message: "Too many messages from your connection. Try again in a minute.", fields };
  }

  const check = await verifyTurnstile(String(formData.get("cf-turnstile-response") ?? ""), await clientIp());
  if (!check.ok) {
    console.warn("turnstile", check.reason);
    return { status: "error", message: "We couldn't confirm you're human. Try the check again.", fields };
  }

  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}`,
      Origin: (await headers()).get("origin") ?? "",
    },
    body: JSON.stringify({ ...fields, _gotcha: String(formData.get("_gotcha") ?? "") }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { status: "sent", message: "Thanks. We'll reply by email." };
  return { status: "error", message: result?.error?.message ?? "Your message didn't send. Try again.", fields };
}

Na Vercel, o limite vem do Firewall. Abra Firewall no seu projeto, adicione uma regra cuja condição seja @vercel/firewall, defina o Rate limit ID como contact-form e escolha uma janela como 5 requisições por minuto. checkRateLimit recebe os cabeçalhos da requisição, então funciona dentro de uma server action, onde não há objeto Request, e agrupa por IP do cliente a menos que você passe um rateLimitKey.

  • A Vercel conta cada regra por região, então tráfego espalhado entre regiões pode ultrapassar o número definido. Para um formulário de contato, tudo bem; para uma contagem global exata, use Redis, como na página de limite de taxa.
  • O cabeçalho Origin do visitante é repassado porque um fetch do lado do servidor não envia nenhum, e o Ikiform o lê para reconhecer testes gratuitos em localhost.
  • Fora da Vercel, a chamada ao Firewall é pulada. Coloque ali um limitador com Redis ou Workers, baseado em clientIp().
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 Relaxed ou Custom para um formulário enviado por um servidor e mantenha IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.

O formulário e o widget

app/contact/contact-form.tsx
"use client";

import Script from "next/script";
import { useActionState, useEffect, useRef } from "react";
import { type ContactState, sendMessage } from "./actions";

interface Turnstile {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

declare global {
  interface Window {
    turnstile?: Turnstile;
  }
}

const initial: ContactState = { status: "idle", message: "" };

export function ContactForm({ nonce }: { nonce?: string }) {
  const [state, action, pending] = useActionState(sendMessage, initial);
  const box = useRef<HTMLDivElement>(null);
  const widget = useRef<string | null>(null);

  function mount() {
    if (!box.current || !window.turnstile || widget.current) return;
    widget.current = window.turnstile.render(box.current, {
      sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!,
      action: "contact",
    });
  }

  useEffect(() => {
    if (state.status === "error" && widget.current) window.turnstile?.reset(widget.current);
  }, [state]);

  useEffect(
    () => () => {
      if (widget.current) window.turnstile?.remove(widget.current);
      widget.current = null;
    },
    [],
  );

  if (state.status === "sent") return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} defaultValue={state.fields?.name} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.fields?.email} />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} defaultValue={state.fields?.message} />
      <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>
      <div ref={box} />
      {state.status === "error" ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
      <Script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" nonce={nonce} onReady={mount} />
    </form>
  );
}
  • ?render=explicit impede o script de varrer a página atrás de widgets. onReady roda quando o script carrega e de novo toda vez que o componente é montado, o que cobre a navegação no cliente.
  • Um token funciona uma vez. Quando a action retorna um erro, o effect reinicia o widget para que a próxima tentativa leve um token novo.
  • O formulário também envia antes da hidratação, porque action é a própria server action. Sem JavaScript não há widget, então o siteverify recusa o envio; a verificação humana precisa de um navegador.
  • A classe .hp do guia do honeypot esconde o honeypot fora da tela.
app/contact/page.tsx
import { headers } from "next/headers";
import { ContactForm } from "./contact-form";

export default async function Page() {
  const nonce = (await headers()).get("x-nonce") ?? undefined;

  return (
    <main>
      <h1>Contact us</h1>
      <ContactForm nonce={nonce} />
    </main>
  );
}

Uma CSP que deixa o Turnstile rodar

O Next.js lê o nonce do cabeçalho de requisição Content-Security-Policy e o adiciona aos próprios scripts e a qualquer <Script> que receba a prop nonce. A Cloudflare diz que o Turnstile repassa o nonce ao que ele carrega, e que o iframe dele precisa de frame-src https://challenges.cloudflare.com. No Next.js 16 o arquivo é proxy.ts, que substituiu middleware.ts:

proxy.ts
import { type NextRequest, NextResponse } from "next/server";

export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const dev = process.env.NODE_ENV === "development";
  const policy = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://challenges.cloudflare.com${dev ? " 'unsafe-eval'" : ""}`,
    "frame-src https://challenges.cloudflare.com",
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' blob: data:",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
  ].join("; ");

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);
  requestHeaders.set("Content-Security-Policy", policy);
  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("Content-Security-Policy", policy);
  return response;
}

export const config = {
  matcher: [
    {
      source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};
  • Com 'strict-dynamic', navegadores modernos confiam no nonce e ignoram o host, que fica para navegadores mais antigos.
  • Ler headers() na página faz com que ela renderize a cada requisição, o que um nonce exige. Uma página de contato gerada estaticamente não pode ter um; use uma política sem nonces em headers() no next.config, ao custo de 'unsafe-inline'.

Deploy

HostCLIENT_IP_HEADERLimite de taxaObservações
Vercelx-real-ipRegra do Firewall e checkRateLimitAdicione uma regra por ambiente em que você testa
Netlifyx-nf-client-connection-ipRedis, como o UpstashAs server actions rodam numa única função da Netlify
Cloudflare, com OpenNextcf-connecting-ipBinding de rate limiting do WorkersO binding é aproximado e contado por localidade

Defina TURNSTILE_HOSTNAME com seu domínio real em produção. Deploys de preview têm hostnames próprios, então adicione-os ao widget ou deixe a verificação desligada no preview.

Deixe o Ikiform rodar as verificações

Tudo acima existe porque a server action repassa com uma chave secreta, o que desliga a verificação humana e o limite por visitante do próprio Ikiform. Envie a partir do navegador e o Ikiform roda o honeypot, o Turnstile e o limite de taxa para você. Não há chaves da Cloudflare, nenhuma mudança em proxy.ts além de permitir https://ikiform.com em frame-src e connect-src, e nenhuma action:

app/contact/contact-form.tsx
"use client";

import { challenge, submit } from "@ikiform/sdk";
import { useActionState, useRef } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

export function ContactForm() {
  const frame = useRef<HTMLDivElement>(null);
  const [message, action, pending] = useActionState(async (_previous: string, data: FormData) => {
    try {
      const turnstileToken = await challenge(FORM_ID, { container: frame.current ?? undefined });
      const result = await submit(FORM_ID, data, { turnstileToken });
      return result.ok ? "sent" : result.error.message;
    } catch {
      return "The human check didn't finish. Try again.";
    }
  }, "");

  if (message === "sent") return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <form action={action}>
      <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} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <div ref={frame} />
      {message ? <p role="alert">{message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}

Defina o Spam check do formulário como Ikiform e o Rate limit como Strict, 2 a cada 10 minutos por visitante, e adicione seu domínio e localhost em Allowed domains. challenge() monta um pequeno frame de ikiform.com que fica invisível a não ser que o Turnstile precise de um clique. O spam recebe a mesma resposta de sucesso que uma mensagem real, é guardado com os motivos pelos quais foi barrado e fica guardado por 30 dias antes de ser apagado. O guia de configuração do Next.js cobre a criação do formulário e das chaves.

Confira se funciona

  1. Rode next dev com as chaves de teste e envie uma mensagem. Ela chega em Submissions. Sem plano, um workspace aceita 50 envios de teste por mês a partir de localhost.
  2. Defina TURNSTILE_SECRET_KEY com o segredo que sempre falha e envie. O formulário mostra o erro da verificação humana e mantém seu texto.
  3. Num preview da Vercel com a regra do Firewall, envie seis vezes em um minuto. O sexto mostra a mensagem de limite.
  4. 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.
  5. Abra a página com o console aberto. Não há violações de CSP para challenges.cloudflare.com.

Pegadinhas

ProblemaO que fazer
O widget nunca aparece depois de navegar até a páginaO script foi carregado numa página anterior e não varre de novo. Renderize a partir de onReady, como no exemplo, não de onLoad.
Toda mensagem falha na verificação humana em produçãoTURNSTILE_HOSTNAME não bate com o hostname que o siteverify retorna, muitas vezes www. contra o domínio raiz. Registre check.reason em log para ver qual é.
Todos os visitantes compartilham um único bucket de limite de taxaCLIENT_IP_HEADER indica um cabeçalho que seu host não define, então clientIp() retorna null. Escolha o cabeçalho na tabela acima.
checkRateLimit retorna error: "not-found"Nenhuma regra do Firewall tem o Rate limit ID contact-form neste ambiente. Adicione-a e depois publique as mudanças do Firewall.

Perguntas

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.