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.
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.
- O formulário, ou um script que o repete, envia à server action.
- A action confere a contagem do visitante contra uma regra do Vercel Firewall. Acima do limite, ela retorna um estado de erro e para.
- Ela envia o token do Turnstile e o IP do cliente ao siteverify e confere o nome da action e o hostname na resposta.
- Só então ela repassa os campos, honeypot incluído, ao Ikiform, que pontua e guarda a mensagem.
Configuração do projeto
Os arquivos que esta página adiciona a um projeto App Router:
app/
contact/
page.tsx
contact-form.tsx
actions.ts
lib/
client-ip.ts
turnstile.ts
proxy.ts
.env.localCrie 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:
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_KEYchega ao navegador. O Next.js embute valoresNEXT_PUBLIC_em tempo de build, então mudar a site key exige um novo build. TURNSTILE_HOSTNAMEfica 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
2x0000000000000000000000000000000AApara 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:
- clientIp() lê o único cabeçalho indicado em CLIENT_IP_HEADER e ignora o resto.
- A Vercel sobrescreve x-forwarded-for e x-real-ip com o endereço do visitante.
- A Netlify coloca o endereço em x-nf-client-connection-ip.
- A Cloudflare, inclusive com Next.js publicado via OpenNext, define cf-connecting-ip.
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
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. successsozinho não basta. Um token emitido em outro site com a sua site key também dizsuccess, então o helper confereactione, 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
"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
Origindo visitante é repassado porque um fetch do lado do servidor não envia nenhum, e o Ikiform o lê para reconhecer testes gratuitos emlocalhost. - Fora da Vercel, a chamada ao Firewall é pulada. Coloque ali um limitador com Redis ou Workers, baseado em
clientIp().
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.O formulário e o widget
"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=explicitimpede o script de varrer a página atrás de widgets.onReadyroda 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
.hpdo guia do honeypot esconde o honeypot fora da tela.
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:
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 emheaders()nonext.config, ao custo de'unsafe-inline'.
Deploy
| Host | CLIENT_IP_HEADER | Limite de taxa | Observações |
|---|---|---|---|
| Vercel | x-real-ip | Regra do Firewall e checkRateLimit | Adicione uma regra por ambiente em que você testa |
| Netlify | x-nf-client-connection-ip | Redis, como o Upstash | As server actions rodam numa única função da Netlify |
| Cloudflare, com OpenNext | cf-connecting-ip | Binding de rate limiting do Workers | O 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:
"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
- Rode
next devcom 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 delocalhost. - Defina
TURNSTILE_SECRET_KEYcom o segredo que sempre falha e envie. O formulário mostra o erro da verificação humana e mantém seu texto. - Num preview da Vercel com a regra do Firewall, envie seis vezes em um minuto. O sexto mostra a mensagem de limite.
- 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. - Abra a página com o console aberto. Não há violações de CSP para
challenges.cloudflare.com.
Pegadinhas
| Problema | O que fazer |
|---|---|
| O widget nunca aparece depois de navegar até a página | O 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ção | TURNSTILE_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 taxa | CLIENT_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
Renderize o widget num client component, carregue o script com next/script e leia o campo cf-turnstile-response na server action. Envie-o com sua chave secreta ao siteverify e só continue quando a resposta disser success com a action e o hostname esperados.
Leia-o do cabeçalho que seu host define, com headers() de next/headers: x-real-ip ou x-forwarded-for na Vercel, x-nf-client-connection-ip na Netlify, cf-connecting-ip na Cloudflare. O Next.js não tem request.ip no App Router.
Sim. Um nonce é criado a cada requisição, então a página precisa renderizar a cada requisição. Mantenha a página de contato dinâmica, ou use uma política sem nonces que permita challenges.cloudflare.com pelo host.
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.