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

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

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.

Diagrama: A ordem em que as verificações rodam dentro da server action do Next.js

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

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

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

Diagrama: Qual cabeçalho traz o IP real do cliente em cada host

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

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

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

```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](https://ikiform.com/pt-br/guides/spam-protection/rate-limiting.md).
- 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](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.

## O formulário e o widget

`app/contact/contact-form.tsx`:

```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](https://ikiform.com/pt-br/guides/spam-protection/honeypot.md) esconde o honeypot fora da tela.

`app/contact/page.tsx`:

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

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

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

`app/contact/contact-form.tsx`:

```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](https://ikiform.com/pt-br/docs/guides/nextjs.md) 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

| 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

### Como adiciono o Cloudflare Turnstile a uma server action do Next.js?

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.

### Como obtenho o IP do cliente numa server action do Next.js?

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.

### Um nonce de CSP impede que a página de contato seja estática?

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.

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