Acabe com o spam no formulário de contato8 min de leitura
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.
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.
- O componente envia os campos e o token do Turnstile para /api/contact com $fetch.
- A server route lê o IP do cliente do cabeçalho indicado no runtimeConfig.
- Ela conta os envios do IP no minuto atual no Nitro storage. Acima de cinco, lança um 429 com os segundos restantes.
- Dentro do limite, ela valida o token com o siteverify, conferindo a action e o host.
- Depois repassa os campos ao Ikiform, que os guarda e pontua o honeypot.
Configuração do projeto
app/
components/
ContactForm.vue
composables/
useTurnstile.ts
server/
api/
contact.post.ts
utils/
spam.ts
nuxt.config.ts
.envNUXT_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-forconst 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
runtimeConfigpode ser sobrescrita em tempo de execução por uma variávelNUXT_:turnstileSecretKeyporNUXT_TURNSTILE_SECRET_KEY,public.turnstileSiteKeyporNUXT_PUBLIC_TURNSTILE_SITE_KEY. Chaves fora depublicnunca chegam ao navegador. routeRulesenvia 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 comunidadenuxt-securitypode gerar nonces se você precisar de uma política mais rígida.- O driver
upstashsó é montado em produção e lêUPSTASH_REDIS_REST_URLeUPSTASH_REDIS_REST_TOKENdo ambiente. Ele precisa do pacote@upstash/redis, então instale-o comnpm install @upstash/redis.
Utilitários de servidor
Arquivos em server/utils são importados automaticamente em toda server route:
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;
}
}clientIplê exatamente um cabeçalho, aquele que seu host sobrescreve. DefinaNUXT_CLIENT_IP_HEADERcomox-forwarded-forna Vercel,x-nf-client-connection-ipna Netlify ecf-connecting-ipna 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.
$fetchenvia o corpo do siteverify como JSON, que o siteverify aceita. A verificação de host usagetRequestHoste é pulada em desenvolvimento, porque tokens de teste trazem um hostname fictício.
A server route
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.tsfaz o Nitro responder só aPOST. Qualquer outra coisa recebe405. - 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 trazmessage, que o componente mostra. - O
Origindo visitante é repassado, porque um fetch do lado do servidor não envia nenhum e o Ikiform o lê para reconhecer testes gratuitos emlocalhost. - O honeypot
_gotchavai junto sem alteração, então o Ikiform guarda um preenchido como spam em vez de descartá-lo.
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.O composable e o formulário
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 };
}<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 emwindow. - 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
.hpesconde o honeypot fora da tela, como no guia do honeypot.
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:
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"}'
doneAs 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 cobre o endpoint e as chaves.
Confira se funciona
- Rode
nuxt devcom as chaves de teste e envie uma mensagem. Ela aparece em Submissions. Um workspace sem plano aceita 50 testes por mês a partir delocalhost. - Envie seis mensagens em um minuto. A sexta mostra quantos segundos faltam.
- Defina
NUXT_TURNSTILE_SECRET_KEYcom o segredo que sempre falha e reinicie. Todo envio mostra o erro da verificação humana. - Requisite
/contactcomcurl -Idepois donuxt build. A resposta traz suaContent-Security-Policy. - 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
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.
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.
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.
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.