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

> Proteja um formulário Nuxt contra spam: server route do Nitro com Turnstile, limite por IP no Nitro storage, segredos no runtimeConfig e CSP via routeRules.

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

Envie o formulário com `$fetch` a uma server route que conta tentativas por IP do cliente no Nitro storage, valida o token do Turnstile com o siteverify e repassa os campos com o honeypot. Guarde os segredos no `runtimeConfig` privado, definidos por variáveis de ambiente `NUXT_` em tempo de execução. Envie a CSP da página de contato pelas `routeRules`, permitindo `challenges.cloudflare.com` para o script e o frame dele.

## O que muda no Nuxt

O servidor do Nuxt é o Nitro, e o Nitro traz duas coisas de que uma verificação de spam precisa. As server routes em `server/api` são endpoints simples com o `$fetch` já ligado a elas, e `useStorage()` dá um armazenamento chave-valor cujo backend você escolhe por ambiente: memória em desenvolvimento, Redis ou outro driver em produção. Isso faz do limite de taxa uma mudança de configuração, e não uma dependência.

Dois hábitos do Nuxt causam problemas aqui. `getRequestIP(event, { xForwardedFor: true })` retorna a primeira entrada de `X-Forwarded-For`, que a própria documentação do h3 chama de input do cliente. E arquivos `.env` não são lidos em produção, então os segredos precisam ser definidos como variáveis de ambiente da plataforma.

Diagrama: Um envio de contato pela server route do Nuxt e pelo Nitro storage

1. O componente envia os campos e o token do Turnstile para /api/contact com $fetch.
2. A server route lê o IP do cliente do cabeçalho indicado no runtimeConfig.
3. Ela conta os envios do IP no minuto atual no Nitro storage. Acima de cinco, lança um 429 com os segundos restantes.
4. Dentro do limite, ela valida o token com o siteverify, conferindo a action e o host.
5. Depois repassa os campos ao Ikiform, que os guarda e pontua o honeypot.

Em desenvolvimento, o storage é montado em memória. Em produção, o mesmo código fala com o Redis, porque o nuxt.config monta outro driver.

## Configuração do projeto

`Arquivos`:

```text
app/
  components/
    ContactForm.vue
  composables/
    useTurnstile.ts
server/
  api/
    contact.post.ts
  utils/
    spam.ts
nuxt.config.ts
.env
```

`.env`:

```bash
NUXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
NUXT_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_CLIENT_IP_HEADER=x-forwarded-for
```

`nuxt.config.ts`:

```ts
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'",
].join("; ");

export default defineNuxtConfig({
  runtimeConfig: {
    turnstileSecretKey: "",
    ikiformSecretKey: "",
    clientIpHeader: "x-forwarded-for",
    public: {
      turnstileSiteKey: "",
    },
  },
  routeRules: {
    "/contact": { headers: { "Content-Security-Policy": policy } },
  },
  $production: {
    nitro: {
      storage: {
        ratelimit: { driver: "upstash" },
      },
    },
  },
});
```

- Toda chave do `runtimeConfig` pode ser sobrescrita em tempo de execução por uma variável `NUXT_`: `turnstileSecretKey` por `NUXT_TURNSTILE_SECRET_KEY`, `public.turnstileSiteKey` por `NUXT_PUBLIC_TURNSTILE_SITE_KEY`. Chaves fora de `public` nunca chegam ao navegador.
- `routeRules` envia a CSP só em `/contact`. O Nuxt embute o payload e os scripts de hidratação inline, então esta política mantém `'unsafe-inline'`; o módulo da comunidade `nuxt-security` pode gerar nonces se você precisar de uma política mais rígida.
- O driver `upstash` só é montado em produção e lê `UPSTASH_REDIS_REST_URL` e `UPSTASH_REDIS_REST_TOKEN` do ambiente. Ele precisa do pacote `@upstash/redis`, então instale-o com `npm install @upstash/redis`.

## Utilitários de servidor

Arquivos em `server/utils` são importados automaticamente em toda server route:

`server/utils/spam.ts`:

```ts
import type { H3Event } from "h3";

const WINDOW_SECONDS = 60;
const LIMIT = 5;

export function clientIp(event: H3Event) {
  const header = useRuntimeConfig(event).clientIpHeader;
  return getRequestHeader(event, header)?.split(",")[0]?.trim() || "unknown";
}

export async function secondsToWait(ip: string) {
  const storage = useStorage("ratelimit");
  const window = Math.floor(Date.now() / 1000 / WINDOW_SECONDS);
  const key = `contact:${ip}:${window}`;
  const count = ((await storage.getItem<number>(key)) ?? 0) + 1;
  await storage.setItem(key, count);
  if (count <= LIMIT) return 0;
  return (window + 1) * WINDOW_SECONDS - Math.floor(Date.now() / 1000);
}

export async function isHuman(event: H3Event, token: string, ip: string) {
  if (!token || token.length > 2048) return false;
  try {
    const verdict = await $fetch<{ success: boolean; hostname?: string; action?: string }>("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      body: { secret: useRuntimeConfig(event).turnstileSecretKey, response: token, remoteip: ip },
    });
    return verdict.success && verdict.action === "contact" && (import.meta.dev || verdict.hostname === getRequestHost(event));
  } catch {
    return false;
  }
}
```

- `clientIp` lê exatamente um cabeçalho, aquele que seu host sobrescreve. Defina `NUXT_CLIENT_IP_HEADER` como `x-forwarded-for` na Vercel, `x-nf-client-connection-ip` na Netlify e `cf-connecting-ip` na Cloudflare.
- O contador é uma janela fixa no Nitro storage. Ler e depois escrever não é atômico, então dois envios no mesmo instante podem passar abaixo do limite; para um formulário de contato isso é aceitável. As chaves não expiram sozinhas, então dê ao banco do Redis uma política de remoção.
- `$fetch` envia o corpo do siteverify como JSON, que o siteverify aceita. A verificação de host usa `getRequestHost` e é pulada em desenvolvimento, porque tokens de teste trazem um hostname fictício.

## A server route

`server/api/contact.post.ts`:

```ts
interface ContactBody {
  name?: string;
  email?: string;
  company?: string;
  message?: string;
  _gotcha?: string;
  "cf-turnstile-response"?: string;
}

export default defineEventHandler(async (event) => {
  const ip = clientIp(event);
  const wait = await secondsToWait(ip);
  if (wait) throw createError({ status: 429, message: `Too many messages. Try again in ${wait} seconds.` });

  const body = await readBody<ContactBody>(event);
  const fields = {
    name: body.name?.trim().slice(0, 120) ?? "",
    email: body.email?.trim() ?? "",
    company: body.company?.trim().slice(0, 120) ?? "",
    message: body.message?.trim().slice(0, 5000) ?? "",
    _gotcha: body._gotcha ?? "",
  };
  if (!fields.email.includes("@")) throw createError({ status: 400, message: "Enter an address we can reply to." });
  if (!(await isHuman(event, body["cf-turnstile-response"] ?? "", ip))) {
    throw createError({ status: 403, message: "We couldn't confirm you're human. Try again." });
  }

  const origin = getRequestHeader(event, "origin");
  await $fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${useRuntimeConfig(event).ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: fields,
  }).catch((failure) => {
    throw createError({
      status: failure.status ?? 502,
      message: failure.data?.error?.message ?? "Your message didn't send. Try again.",
    });
  });
  return { sent: true };
});
```

- O sufixo `.post.ts` faz o Nitro responder só a `POST`. Qualquer outra coisa recebe `405`.
- O limite roda antes de `readBody`, então uma enxurrada nunca custa a leitura do corpo nem uma chamada ao siteverify.
- Cada falha lança `createError`. O corpo JSON traz `message`, que o componente mostra.
- O `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`.
- O honeypot `_gotcha` vai junto sem alteração, então o Ikiform guarda um preenchido como spam em vez de descartá-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.

## O composable e o formulário

`app/composables/useTurnstile.ts`:

```ts
interface TurnstileApi {
  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<TurnstileApi> | undefined;

function load() {
  loading ??= new Promise<TurnstileApi>((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?: TurnstileApi }).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(action: string) {
  const { turnstileSiteKey } = useRuntimeConfig().public;
  const box = ref<HTMLElement>();
  const token = ref("");
  let api: TurnstileApi | undefined;
  let widgetId: string | undefined;

  onMounted(async () => {
    api = await load();
    if (!box.value) return;
    widgetId = api.render(box.value, {
      sitekey: turnstileSiteKey,
      action,
      callback: (value) => (token.value = value),
      "expired-callback": () => (token.value = ""),
    });
  });

  onBeforeUnmount(() => {
    if (widgetId) api?.remove(widgetId);
  });

  function reset() {
    token.value = "";
    if (widgetId) api?.reset(widgetId);
  }

  return { box, token, reset };
}
```

`app/components/ContactForm.vue`:

```vue
<script setup lang="ts">
const fields = reactive({ name: "", email: "", company: "", message: "", _gotcha: "" });
const { box, token, reset } = useTurnstile("contact");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");

async function send() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/contact", { method: "POST", body: { ...fields, "cf-turnstile-response": token.value } });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send your message. Try again.";
    status.value = "idle";
    reset();
  }
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="fields.name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" v-model="fields.email" type="email" autocomplete="email" required />
    <label for="company">Company <span>(optional)</span></label>
    <input id="company" v-model="fields.company" autocomplete="organization" maxlength="120" />
    <label for="message">Message</label>
    <textarea id="message" v-model="fields.message" rows="5" required minlength="10" />
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" v-model="fields._gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div ref="box" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending' || !token">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

- O composable só roda em `onMounted`, então a renderização no servidor nunca mexe em `window`.
- O botão espera um token, e `reset()` pede um novo após cada falha, porque um token funciona uma vez e dura 300 segundos.
- Este formulário precisa de JavaScript. Para uma versão que envia sem ele, veja a próxima seção.
- A classe `.hp` esconde o honeypot fora da tela, como no [guia do honeypot](https://ikiform.com/pt-br/guides/spam-protection/honeypot.md).

## Teste localmente

Em desenvolvimento, o `ratelimit` é montado em memória, então a contagem zera quando o `nuxt dev` reinicia. Você pode ver o limite funcionar sem navegador. Envios sem token falham no siteverify com `403`, e cada um ainda conta:

`Terminal`:

```bash
for i in 1 2 3 4 5 6; do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/contact \
    -H "Content-Type: application/json" -d '{"email":"test@example.com"}'
done
```

As cinco primeiras linhas imprimem `403` e a sexta imprime `429`. Localmente nenhum proxy define o cabeçalho de IP, então toda requisição conta como `unknown`; isso é esperado, e é por isso que o cabeçalho fica na configuração e não no código.

## Deploy

| Host | NUXT_CLIENT_IP_HEADER | Driver de storage |
| --- | --- | --- |
| Vercel | `x-forwarded-for` | `upstash`, do Vercel Marketplace |
| Netlify | `x-nf-client-connection-ip` | `upstash` ou `redis` |
| Cloudflare | `cf-connecting-ip` | `upstash`; o Workers KV tem consistência eventual, então as contagens atrasam |
| Servidor Node | O cabeçalho que seu proxy define | `redis`, ou memória numa única instância |

Defina todas as variáveis `NUXT_` no painel do host. O Nuxt as lê quando o servidor inicia, então mudar uma chave exige reiniciar, não refazer o build. Adicione cada hostname de produção e de preview ao widget do Turnstile.

## Deixe o Ikiform rodar as verificações

Sem a server route, a página de contato pode ser pré-renderizada com o resto de um site `nuxt generate`. Aponte um formulário simples para o seu endpoint do Ikiform com `method="post"` e `action` com a URL do endpoint, e apague `server/api/contact.post.ts` e o composable.

Defina o **Spam check** do formulário como **Ikiform**. Um envio sem token recebe uma breve página de verificação em ikiform.com que roda o Turnstile e repassa os campos, então sua CSP não precisa de entradas da Cloudflare. Escolha o **Rate limit** Strict, 2 a cada 10 minutos por visitante, e liste seu domínio em **Allowed domains**. O Ikiform guarda o spam barrado por 30 dias com os motivos pelos quais foi barrado. O [guia de configuração do Nuxt](https://ikiform.com/pt-br/docs/guides/nuxt.md) cobre o endpoint e as chaves.

## Confira se funciona

1. Rode `nuxt dev` com as chaves de teste e envie uma mensagem. Ela aparece em **Submissions**. Um workspace sem plano aceita 50 testes por mês a partir de `localhost`.
2. Envie seis mensagens em um minuto. A sexta mostra quantos segundos faltam.
3. Defina `NUXT_TURNSTILE_SECRET_KEY` com o segredo que sempre falha e reinicie. Todo envio mostra o erro da verificação humana.
4. Requisite `/contact` com `curl -I` depois do `nuxt build`. A resposta traz sua `Content-Security-Policy`.
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 |
| --- | --- |
| Os segredos ficam vazios em produção | O `.env` não é lido em tempo de execução em produção. Defina `NUXT_TURNSTILE_SECRET_KEY` e as outras no ambiente do host. |
| O limite zera a cada deploy ou instância | O storage ainda está montado em memória. Confira se `$production.nitro.storage` monta um driver compartilhado e se as credenciais dele estão definidas. |
| Um script passa pelo limite mudando cabeçalhos | Você lê `getRequestIP` com `xForwardedFor: true` num host que não sobrescreve esse cabeçalho. Leia o cabeçalho do próprio host por `clientIp`. |
| `window is not defined` durante a renderização no servidor | Código do Turnstile rodou fora de `onMounted`. Mantenha todo acesso a `window` dentro dos lifecycle hooks do composable. |

## Perguntas

### Como adiciono o Turnstile a um formulário Nuxt?

Renderize o widget explicitamente em onMounted, guarde o token num ref e envie-o com os campos a uma server route. A rota o envia com sua chave secreta do runtimeConfig ao siteverify e só continua em caso de sucesso.

### Como limito a taxa de uma server route do Nuxt?

Conte as requisições por IP do cliente com o useStorage do Nitro, montado no Redis ou no Upstash em produção para que todas as instâncias compartilhem a contagem. Lance um 429 com createError quando a contagem passar do limite.

### O getRequestIP é seguro para limitar a taxa no Nuxt?

Não com xForwardedFor true num host que repassa o X-Forwarded-For do próprio cliente, porque a primeira entrada é o que o cliente mandou. Leia o cabeçalho que seu host sobrescreve.

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