Guias18 min de leitura
Como acabar com o spam no formulário de contato
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.
Empilhe verificações baratas e invisíveis antes de adicionar qualquer atrito: um campo honeypot oculto, um tempo mínimo para preencher o formulário, uma pontuação para links e frases de spam e um limite de taxa por visitante. Adicione uma verificação humana como o Cloudflare Turnstile só quando o spam ainda passar, e valide o token dela num servidor. Guarde o que você barrar em vez de jogar fora, para poder resgatar uma ou outra mensagem real.
Um formulário de contato numa página pública começa a receber lixo poucos dias depois de ser indexado. A maior parte nem é dirigida a você. Scripts varrem a web atrás de qualquer <form>, preenchem todos os campos e enviam, na esperança de que algum link pegue. Uma parte menor vem de navegadores reais controlados por software, e outra de pessoas pagas para colar o mesmo discurso em milhares de formulários. Cada tipo é barrado por uma verificação diferente, e é por isso que um truque só nunca resolve.
Este guia explica cada defesa, o que ela barra e quanto custa para visitantes reais, com código que roda em qualquer stack. Depois mostra como o Ikiform, um backend de formulários, aplica as mesmas camadas para você.
Camadas, da mais barata para a mais cara
Ordene as verificações pelo custo. Contar requisições é quase de graça, então isso roda antes de qualquer leitura do corpo. Verificações que chamam outro serviço, como um captcha, rodam depois que as baratas passam. A pontuação do conteúdo vem por último, porque precisa dos campos já interpretados. O Ikiform roda as verificações nessa ordem em todo envio.
- Um envio chega ao endpoint do formulário.
- O limite de taxa conta primeiro os envios por visitante. Uma enxurrada é recusada com 429 antes de o corpo ser lido.
- Os domínios permitidos comparam a página de onde veio o envio com os domínios que você listou.
- Se a verificação humana estiver ativa, o envio precisa trazer um token válido do Turnstile.
- O honeypot soma 5 à pontuação quando é preenchido.
- Tempo, links, frases de spam conhecidas, endereços descartáveis e repetições somam valores menores.
- Com 5 ou mais, o envio é guardado como spam. Abaixo disso, o envio é recebido e seu e-mail e seus webhooks são disparados.
As defesas comparadas
Toda defesa tem um preço. A tabela é honesta sobre o que cada uma deixa passar e o que ela exige das pessoas de quem você quer ouvir.
| Defesa | Barra | Deixa passar | Custo para visitantes reais |
|---|---|---|---|
| Honeypot | Scripts que preenchem todos os campos | Bots que leem CSS ou pulam campos ocultos, e humanos | Nenhum, se ficar oculto para leitores de tela e preenchimento automático |
| Armadilha de tempo | Envios mais rápidos do que alguém consegue digitar | Bots lentos e bots que esperam | Nenhum, desde que o mínimo seja de poucos segundos |
| Pontuação de conteúdo, como contagem de links, frases de spam ou Akismet | Ofertas de SEO, links soltos, spam de cassino e de remédios | Spam com cara de limpo, e pode marcar uma mensagem real com links | Nenhum, se você revisar o que ela barra |
| Rate limiting | Enxurradas e scripts em loop | Spam lento e espalhado | Escritórios e eventos atrás de um único IP podem bater num limite apertado |
| Domínios permitidos | Envios mandados direto para o seu endpoint de outro lugar | Scripts que falsificam o cabeçalho Origin | Nenhum |
| Cloudflare Turnstile | A maioria dos navegadores automatizados | Spammers humanos pagos | Geralmente invisível. Às vezes um clique, e exige um script |
| reCAPTCHA ou hCaptcha | A maioria dos navegadores automatizados | Spammers humanos pagos e serviços de resolução | Quebra-cabeças de imagem para alguns visitantes, mais um script de terceiros |
Comece pelas cinco primeiras. Elas são invisíveis e barram a maior parte do que um site pequeno recebe. Adicione uma verificação humana quando o painel mostrar que o spam ainda passa, não antes: é a única camada com um custo que todo visitante pode sentir. A página sobre acabar com o spam sem captcha mostra até onde as camadas invisíveis chegam sozinhas.
Leia o spam antes de combatê-lo
Olhe dez mensagens de spam antes de escolher uma solução. O que elas têm em comum diz quem as envia, e isso aponta para a camada que as barra.
- Texto aleatório em todos os campos vem de um script que preenche tudo. Um honeypot pega.
- Dezenas de envios por minuto de uma mesma origem vêm de um script em loop. Um limite de taxa barra.
- Envios bem-feitos mandados um segundo após a página carregar vêm de um navegador headless. Uma armadilha de tempo ou uma verificação humana barra.
- Ofertas educadas com links vêm de pessoas pagas por formulário. Só a pontuação de conteúdo e a sua própria revisão pegam.
Um formulário de contato com as camadas integradas
Aqui está um formulário de contato, montado para cada stack. Toda versão tem o honeypot fora da tela e uma verificação humana. As versões só de navegador usam a verificação Turnstile gerenciada do Ikiform, que dispensa chaves. As versões de servidor mostram o caminho faça-você-mesmo: seu servidor valida um token do Turnstile com suas próprias chaves da Cloudflare e depois repassa os campos. Escolha sua stack abaixo; a escolha vale para todos os exemplos de código destes guias.
As abas de servidor compartilham dois pequenos helpers. verifyTurnstile pergunta à Cloudflare se um token é real, e sendToIkiform repassa os campos com sua chave secreta e o cabeçalho Origin do visitante. Um fetch do lado do servidor não envia Origin próprio, e o Ikiform o lê para reconhecer um teste gratuito em localhost. Os dois falham de forma fechada: se a Cloudflare não responder, o visitante vê um erro em vez de passar em silêncio.
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export interface Contact {
name: string;
email: string;
message: string;
_gotcha: string;
}
export function readContact(data: FormData): Contact {
return {
name: String(data.get("name") ?? "").trim(),
email: String(data.get("email") ?? "").trim(),
message: String(data.get("message") ?? "").trim(),
_gotcha: String(data.get("_gotcha") ?? ""),
};
}
export async function verifyTurnstile(input: { secret: string; token: string; ip?: string | null }) {
if (!input.token || input.token.length > 2048) return false;
const body = new URLSearchParams({ secret: input.secret, response: input.token });
if (input.ip) body.set("remoteip", input.ip);
try {
const response = await fetch(SITEVERIFY, { method: "POST", body });
const result = (await response.json()) as { success?: boolean };
return result.success === true;
} catch {
return false;
}
}
export async function sendToIkiform(secretKey: string, contact: Contact, origin?: string | null) {
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${secretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(contact),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { ok: true as const };
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const first = Object.values(fields)[0]?.[0];
return {
ok: false as const,
status: response.status,
message: String(first ?? result?.error?.message ?? "Couldn't send your message. Try again."),
};
} catch {
return { ok: false as const, status: 502, message: "Couldn't reach the form service. Try again." };
}
}O formulário simples não precisa de script. Defina o Spam check do formulário no Ikiform como Ikiform, e um envio sem token recebe uma breve página de verificação em ikiform.com que roda o Turnstile e repassa os campos. Quem passa de forma invisível nem percebe.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required minlength="10" maxlength="5000"></textarea>
<div class="hp" aria-hidden="true">
<label for="hp-field">Leave this empty</label>
<input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<button>Send message</button>
</form>
<style>
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>O honeypot fica fora da tela em vez de atrás de display: none, porque alguns bots pulam campos ocultos desse jeito. O aria-hidden do contêiner e o tabindex do campo mantêm leitores de tela e teclados longe dele.
Uma server action valida o token do Turnstile com suas próprias chaves e depois repassa os campos com sua chave secreta do Ikiform. O honeypot vai junto sem alteração, então um preenchido é guardado como spam, onde você pode revisá-lo. Salve os helpers acima como lib/spam.ts. Defina TURNSTILE_SECRET_KEY e IKIFORM_SECRET_KEY como variáveis de ambiente só do servidor e deixe o Spam check do formulário no Ikiform em Off: seu servidor já fez a verificação humana.
"use server";
import { headers } from "next/headers";
import { readContact, sendToIkiform, verifyTurnstile } from "@/lib/spam";
export interface ContactState {
ok: boolean;
message: string;
values?: { name: string; email: string; message: string };
}
export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
const contact = readContact(formData);
const values = { name: contact.name, email: contact.email, message: contact.message };
const incoming = await headers();
const forwarded = incoming.get("x-forwarded-for");
const human = await verifyTurnstile({
secret: process.env.TURNSTILE_SECRET_KEY!,
token: String(formData.get("cf-turnstile-response") ?? ""),
ip: forwarded?.split(",")[0]?.trim(),
});
if (!human) return { ok: false, message: "We couldn't confirm you're human. Try the check again.", values };
const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, contact, incoming.get("origin"));
if (result.ok) return { ok: true, message: "Thanks. We'll reply by email." };
return { ok: false, message: result.message, values };
}"use client";
import { useActionState } from "react";
import { Turnstile } from "@/components/turnstile";
import { type ContactState, sendMessage } from "./actions";
const initial: ContactState = { ok: false, message: "" };
export function ContactForm() {
const [state, action, pending] = useActionState(sendMessage, initial);
if (state.ok) 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.values?.name} />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email} />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={5} required minLength={10} defaultValue={state.values?.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>
<Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} reset={state} />
{state.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
);
}"use client";
import { useEffect, useRef } from "react";
interface TurnstileApi {
render(container: HTMLElement, options: { sitekey: string; action?: string }): string;
remove(widgetId: string): void;
}
declare global {
interface Window {
turnstile?: TurnstileApi;
}
}
const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
function loadTurnstile() {
return new Promise<TurnstileApi>((resolve) => {
if (window.turnstile) return resolve(window.turnstile);
let script = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT}"]`);
if (!script) {
script = document.createElement("script");
script.src = SCRIPT;
script.async = true;
document.head.append(script);
}
script.addEventListener("load", () => window.turnstile && resolve(window.turnstile), { once: true });
});
}
export function Turnstile({ siteKey, reset }: { siteKey: string; reset?: unknown }) {
const box = useRef<HTMLDivElement>(null);
useEffect(() => {
let widgetId: string | undefined;
let active = true;
loadTurnstile().then((turnstile) => {
if (active && box.current) widgetId = turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
});
return () => {
active = false;
if (widgetId) window.turnstile?.remove(widgetId);
};
}, [siteKey, reset]);
return <div ref={box} />;
}O widget é renderizado explicitamente e de novo sempre que a action retorna, porque um token do Turnstile só vale uma vez. defaultValue devolve o texto do visitante após um erro, já que o React reinicia o formulário depois que a action roda.
Quer ir além com Next.js? Leia Proteção contra spam em formulários Next.js com Turnstile.
Uma Astro Action valida os campos com Zod, confere o token com context.clientAddress e repassa o envio. Salve os helpers acima como src/lib/spam.ts e leia as duas chaves com getSecret de astro:env/server. O formulário envia sem JavaScript no cliente, então cada tentativa recarrega a página e o widget é renderizado do zero. Adicione um adapter e mantenha prerender = false. Defina TURNSTILE_SECRET_KEY e IKIFORM_SECRET_KEY como variáveis de ambiente só do servidor e deixe o Spam check do formulário no Ikiform em Off: seu servidor já fez a verificação humana.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
import { sendToIkiform, verifyTurnstile } from "../lib/spam";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1).max(120),
email: z.email(),
message: z.string().trim().min(10).max(5000),
_gotcha: z.string().nullish(),
"cf-turnstile-response": z.string().nullish(),
}),
handler: async (input, context) => {
const human = await verifyTurnstile({
secret: getSecret("TURNSTILE_SECRET_KEY") ?? "",
token: input["cf-turnstile-response"] ?? "",
ip: context.clientAddress,
});
if (!human) throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try the check again." });
const result = await sendToIkiform(getSecret("IKIFORM_SECRET_KEY") ?? "", {
name: input.name,
email: input.email,
message: input.message,
_gotcha: input._gotcha ?? "",
}, context.request.headers.get("origin"));
if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.message });
return { sent: true };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.sendMessage);
const siteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY;
---
{result?.data?.sent ? (
<p role="status">Thanks. We'll reply by email.</p>
) : (
<form method="POST" action={actions.sendMessage}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required minlength="10"></textarea>
<div class="hp" aria-hidden="true">
<label for="hp-field">Leave this empty</label>
<input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<div class="cf-turnstile" data-sitekey={siteKey} data-action="contact"></div>
{result?.error && (
<p role="alert">{isInputError(result.error) ? "Check your name, email and message." : result.error.message}</p>
)}
<button>Send message</button>
</form>
)}
<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>Quer ir além com Astro? Leia Proteção contra spam em formulários Astro com Turnstile.
Uma função POST com createServerFn lê o IP do visitante com getRequestHeader, valida o token e repassa os campos. O componente do widget é o mesmo da aba Next.js, em src/components/turnstile.tsx. Defina TURNSTILE_SECRET_KEY e IKIFORM_SECRET_KEY como variáveis de ambiente só do servidor e deixe o Spam check do formulário no Ikiform em Off: seu servidor já fez a verificação humana.
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
import { Turnstile } from "../components/turnstile";
import { readContact, sendToIkiform, verifyTurnstile } from "../lib/spam";
const sendMessage = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return { contact: readContact(data), token: String(data.get("cf-turnstile-response") ?? "") };
})
.handler(async ({ data }) => {
const human = await verifyTurnstile({
secret: process.env.TURNSTILE_SECRET_KEY!,
token: data.token,
ip: getRequestHeader("x-forwarded-for")?.split(",")[0]?.trim(),
});
if (!human) return { ok: false, message: "We couldn't confirm you're human. Try the check again." };
const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, data.contact, getRequestHeader("origin"));
return result.ok ? { ok: true, message: "Thanks. We'll reply by email." } : { ok: false, message: result.message };
});
export const Route = createFileRoute("/contact")({ component: Contact });
function Contact() {
const send = useServerFn(sendMessage);
const [state, setState] = useState<{ ok: boolean; message: string } | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try {
setState(await send({ data }));
} catch {
setState({ ok: false, message: "Couldn't send your message. Try again." });
} finally {
setPending(false);
}
}
if (state?.ok) return <p role="status">{state.message}</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required maxLength={120} />
<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} />
<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>
<Turnstile siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} reset={state} />
{state?.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
);
}Quer ir além com TanStack Start? Leia Proteção contra spam em formulários TanStack Start com Turnstile.
A action da rota valida o token e repassa o envio. Passar actionData como reset renderiza um widget novo após cada resposta. O componente do widget é o da aba Next.js. Defina TURNSTILE_SECRET_KEY e IKIFORM_SECRET_KEY como variáveis de ambiente só do servidor e deixe o Spam check do formulário no Ikiform em Off: seu servidor já fez a verificação humana.
import { data, Form, useNavigation } from "react-router";
import { Turnstile } from "../components/turnstile";
import { readContact, sendToIkiform, verifyTurnstile } from "../lib/spam";
import type { Route } from "./+types/contact";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const human = await verifyTurnstile({
secret: process.env.TURNSTILE_SECRET_KEY!,
token: String(formData.get("cf-turnstile-response") ?? ""),
ip: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
});
if (!human) return data({ ok: false, message: "We couldn't confirm you're human. Try the check again." }, { status: 403 });
const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, readContact(formData), request.headers.get("origin"));
if (result.ok) return { ok: true, message: "Thanks. We'll reply by email." };
return data({ ok: false, message: result.message }, { status: result.status });
}
export default function Contact({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
if (actionData?.ok) return <p role="status">{actionData.message}</p>;
return (
<Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required maxLength={120} />
<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} />
<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>
<Turnstile siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} reset={actionData} />
{actionData?.message ? <p role="alert">{actionData.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</Form>
);
}Quer ir além com React Router? Leia Proteção contra spam em formulários React Router com Turnstile.
Uma form action padrão valida o token com getClientAddress() e repassa o envio. Esta versão deixa use:enhance de fora de propósito: cada tentativa recarrega a página, então o Turnstile renderiza um widget novo com um token novo. Salve os helpers acima como src/lib/spam.ts. Declare as chaves em src/env.ts com defineEnvVars e dê public: true a PUBLIC_TURNSTILE_SITE_KEY, para que a página possa importá-la de $app/env/public. Defina TURNSTILE_SECRET_KEY e IKIFORM_SECRET_KEY como variáveis de ambiente só do servidor e deixe o Spam check do formulário no Ikiform em Off: seu servidor já fez a verificação humana.
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY, TURNSTILE_SECRET_KEY } from "$app/env/private";
import { readContact, sendToIkiform, verifyTurnstile } from "#lib/spam.js";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, getClientAddress, url }) => {
const data = await request.formData();
const contact = readContact(data);
const values = { name: contact.name, email: contact.email, message: contact.message };
const human = await verifyTurnstile({
secret: TURNSTILE_SECRET_KEY,
token: String(data.get("cf-turnstile-response") ?? ""),
ip: getClientAddress(),
});
if (!human) return fail(403, { values, error: "We couldn't confirm you're human. Try the check again." });
const result = await sendToIkiform(IKIFORM_SECRET_KEY, contact, request.headers.get("origin") ?? url.origin);
if (!result.ok) return fail(result.status, { values, error: result.message });
return { sent: true };
},
} satisfies Actions;<script lang="ts">
import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
</script>
<svelte:head>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</svelte:head>
{#if form?.sent}
<p role="status">Thanks. We'll reply by email.</p>
{:else}
<form method="POST">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.values?.name ?? ""} />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required minlength="10" value={form?.values?.message ?? ""}></textarea>
<div class="hp" aria-hidden="true">
<label for="hp-field">Leave this empty</label>
<input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<div class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
{#if form?.error}
<p role="alert">{form.error}</p>
{/if}
<button>Send message</button>
</form>
{/if}Quer ir além com SvelteKit? Leia Proteção contra spam em formulários SvelteKit com Turnstile.
Uma server route valida o token e repassa o envio. Salve os helpers acima como server/utils/spam.ts e o Nuxt os importa automaticamente. O componente renderiza o Turnstile explicitamente e o reinicia após uma tentativa que falhou. Defina TURNSTILE_SECRET_KEY e IKIFORM_SECRET_KEY como variáveis de ambiente só do servidor e deixe o Spam check do formulário no Ikiform em Off: seu servidor já fez a verificação humana.
export default defineEventHandler(async (event) => {
const body = await readBody<Record<string, string | undefined>>(event);
const config = useRuntimeConfig(event);
const human = await verifyTurnstile({
secret: config.turnstileSecretKey,
token: body["cf-turnstile-response"] ?? "",
ip: getRequestIP(event, { xForwardedFor: true }),
});
if (!human) throw createError({ status: 403, message: "We couldn't confirm you're human. Try the check again." });
const result = await sendToIkiform(config.ikiformSecretKey, {
name: body.name?.trim() ?? "",
email: body.email?.trim() ?? "",
message: body.message?.trim() ?? "",
_gotcha: body._gotcha ?? "",
}, getRequestHeader(event, "origin"));
if (!result.ok) throw createError({ status: result.status, message: result.message });
return { sent: true };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
turnstileSecretKey: "",
public: {
turnstileSiteKey: "",
},
},
});<script setup lang="ts">
interface TurnstileApi {
render(
container: HTMLElement,
options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
): string;
reset(widgetId: string): void;
}
const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
const config = useRuntimeConfig();
const fields = reactive({ name: "", email: "", message: "", _gotcha: "" });
const box = ref<HTMLDivElement>();
const token = ref("");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
let widgetId: string | undefined;
function api() {
return (window as Window & { turnstile?: TurnstileApi }).turnstile;
}
function loadTurnstile() {
return new Promise<TurnstileApi>((resolve) => {
const ready = api();
if (ready) return resolve(ready);
let script = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT}"]`);
if (!script) {
script = document.createElement("script");
script.src = SCRIPT;
script.async = true;
document.head.append(script);
}
script.addEventListener("load", () => api() && resolve(api()!), { once: true });
});
}
onMounted(async () => {
const turnstile = await loadTurnstile();
if (!box.value) return;
widgetId = turnstile.render(box.value, {
sitekey: config.public.turnstileSiteKey,
action: "contact",
callback: (value) => (token.value = value),
"expired-callback": () => (token.value = ""),
});
});
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";
token.value = "";
if (widgetId) api()?.reset(widgetId);
}
}
</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" name="name" autocomplete="name" required maxlength="120" />
<label for="email">Email</label>
<input id="email" v-model="fields.email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" v-model="fields.message" name="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" name="_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>Quer ir além com Nuxt? Leia Proteção contra spam em formulários Nuxt com Turnstile.
Um app de página única não tem servidor para guardar um segredo do Turnstile, então usa a verificação gerenciada do Ikiform. challenge() de @ikiform/sdk monta um pequeno frame de ikiform.com que fica invisível a não ser que o Turnstile precise de um clique, e retorna um token para submit(). Defina Spam check como Ikiform e adicione seu domínio em Allowed domains.
import { challenge, submit } from "@ikiform/sdk";
import { type FormEvent, useRef, useState } from "react";
const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
export function ContactForm() {
const check = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setStatus("sending");
setError("");
try {
const turnstileToken = await challenge(FORM_ID, { container: check.current ?? undefined });
const result = await submit(FORM_ID, data, { turnstileToken });
if (result.ok) {
setStatus("sent");
return;
}
setError(Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message);
} catch {
setError("The human check didn't finish. Try again.");
}
setStatus("idle");
}
if (status === "sent") return <p role="status">Thanks. We'll reply by email.</p>;
return (
<form onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required maxLength={120} />
<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} />
<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={check} />
{error ? <p role="alert">{error}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send message"}</button>
</form>
);
}A mesma verificação gerenciada num single-file component do Vue. O frame aparece dentro da div vazia só quando um visitante precisa clicar.
<script setup lang="ts">
import { challenge, submit } from "@ikiform/sdk";
import { ref } from "vue";
const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const check = ref<HTMLDivElement>();
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
async function send(event: SubmitEvent) {
const data = new FormData(event.target as HTMLFormElement);
status.value = "sending";
error.value = "";
try {
const turnstileToken = await challenge(FORM_ID, { container: check.value });
const result = await submit(FORM_ID, data, { turnstileToken });
if (result.ok) {
status.value = "sent";
return;
}
error.value = Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message;
} catch {
error.value = "The human check didn't finish. Try again.";
}
status.value = "idle";
}
</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" name="name" autocomplete="name" required maxlength="120" />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required minlength="10" />
<div class="hp" aria-hidden="true">
<label for="hp-field">Leave this empty</label>
<input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<div ref="check" />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send message" }}</button>
</form>
</template>- Toda versão usa a classe
.hpdo bloco de estilo da aba HTML. Coloque-a no seu CSS global. - O honeypot se chama
_gotcha, o padrão do Ikiform. As versões de servidor o repassam sem alteração, então um preenchido ainda cai na sua pasta de spam com o motivohoneypot. - O Turnstile adiciona ao formulário um campo oculto
cf-turnstile-response. Um token vale por 300 segundos e funciona uma única vez, então cada tentativa precisa de um novo. - As chaves de teste da Cloudflare permitem rodar tudo isso localmente. O guia do Turnstile lista quais são.
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.Teste a definição de campos abaixo. Ela roda o validador do Ikiform no seu navegador, com as mesmas regras que o servidor aplica antes de calcular qualquer pontuação de spam.
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
422 validation_failed.O honeypot
Um honeypot é um campo que as pessoas nunca veem e os bots preenchem. Se tiver um valor, o envio veio de um software. Não custa nada aos visitantes e pega os bots mais antigos e comuns, os que leem o HTML e preenchem todo campo que encontram.
Escondê-lo bem importa mais que o truque em si. Três coisas dão errado com honeypots descuidados:
- Leitores de tela o anunciam. Um visitante cego ouve um campo a mais, preenche e é marcado como spam. Envolva-o em
aria-hidden="true"e dê a ele um rótulo dizendo para deixá-lo vazio. - O teclado para nele. Navegar pelo formulário com Tab para num campo invisível.
tabindex="-1"o tira da ordem de tabulação. - O preenchimento automático o preenche. Navegadores e gerenciadores de senha preenchem campos pelo nome. Chame o campo de
email2ouphonee alguns vão preenchê-lo para pessoas reais. Use um nome que nenhum preenchimento automático reconheça, como_gotcha, e adicioneautocomplete="off".
O guia do honeypot cobre técnicas para escondê-lo, como os bots o detectam e a verificação no servidor para cada stack. No Ikiform, um honeypot preenchido soma 5 à pontuação, o que já atinge sozinho o limite de 5. Você pode renomear o campo por formulário em Honeypot field.
Tempo
Pessoas levam segundos para preencher um formulário de contato. Muitos bots levam milissegundos. Uma armadilha de tempo registra quando o formulário foi renderizado e confere o intervalo quando ele é enviado. A versão ingênua coloca um timestamp num campo oculto:
const started = Number(formData.get("started"));
const seconds = (Date.now() - started) / 1000;
const tooFast = !Number.isFinite(seconds) || seconds < 3;Um bot pode enviar o número que quiser, então um timestamp simples só barra bots que nem se dão ao trabalho. Assine-o no servidor com um segredo, e o bot não consegue fingir uma renderização mais lenta. A página sobre acabar com o spam sem captcha monta a versão assinada.
O Ikiform assina os horários de renderização para você quando o formulário vem do SDK ou do componente shadcn/ui: eles enviam _ikiform_ts, e um envio feito menos de 2 segundos após a renderização soma 3 à pontuação. Um timestamp forjado ou de um dia atrás soma 3. Formulários HTML simples não enviam um e não são penalizados.
Pontuando o conteúdo
Parte do spam é digitada ou colada por pessoas, então passa por todas as verificações de bot. O que o entrega é o conteúdo: links, frases de venda, endereços descartáveis e o mesmo texto duas vezes. Pontue cada sinal e trate um total alto como spam, em vez de bloquear por um sinal isolado. Um link numa mensagem é normal; um link no campo de nome nunca é.
| Sinal | O Ikiform soma |
|---|---|
| O honeypot foi preenchido | +5 |
| Um link num campo de nome ou assunto | +3 |
| Os mesmos campos enviados a este formulário nas últimas 24 horas | +3 |
| Enviado menos de 2 segundos após o horário de renderização assinado | +3 |
| Três ou mais links somando todos os campos | +2 |
| Uma de 22 frases de spam conhecidas, como "seo services" | +2 |
| Um endereço num domínio de e-mail descartável | +2 |
Com 5 ou mais, o envio é spam. Nenhum sinal leve chega lá sozinho: uma mensagem real com três links soma 2. Dois juntos muitas vezes chegam, como um link no nome e uma frase de spam. Se você mesmo pontuar o conteúdo, o Akismet é a opção hospedada de longa data; muitas equipes começam com algumas expressões regulares como as de cima.
Uma verificação humana
Uma verificação humana pede ao navegador que prove que é controlado por uma pessoa. As modernas, como o Cloudflare Turnstile, quase sempre rodam de forma invisível e só mostram uma caixa de seleção quando estão em dúvida. O widget entrega um token ao navegador; seu servidor precisa enviar esse token ao provedor para validá-lo. Um token que nunca é validado num servidor não barra nada, porque um bot pode pular o widget e enviar sem ele.
O Ikiform tem três opções em Spam check em cada formulário:
| Opção | O que acontece | Mudanças no código |
|---|---|---|
| Off (the default) | Nenhum desafio. O honeypot, o tempo e a pontuação continuam rodando. | Nenhum |
| Ikiform | Turnstile rodado pelo Ikiform em ikiform.com, sem conta nem chaves da Cloudflare | Nenhuma para envios HTML. Envios com fetch chamam antes challenge() de @ikiform/sdk |
| Your own Turnstile keys (suas próprias chaves) | Seu widget e sua chave secreta. O Ikiform valida o token e confere o hostname dele com seus domínios permitidos | Renderize o widget no seu formulário para que ele envie cf-turnstile-response |
Um envio com fetch sem token recebe 403 challenge_required. Se a Cloudflare não responder, o Ikiform mantém o envio e o guarda como spam com o motivo captcha_unavailable, para você poder restaurá-lo. A documentação de spam mostra os dois fluxos. Se você está comparando o Turnstile com o reCAPTCHA do Google ou o hCaptcha, veja alternativas ao reCAPTCHA.
Limites de taxa e domínios permitidos
Um limite de taxa restringe a frequência com que um visitante pode enviar. Ele não distingue spam de mensagem legítima; ele barra volume. Conte por IP e formulário, permita uma pequena rajada e responda 429 com um cabeçalho Retry-After. O guia de limite de taxa monta um para cada stack e explica por que um contador em memória falha em hosts serverless.
O Ikiform conta cada envio com exatidão, antes de ler o corpo. Cada formulário escolhe um preset: Standard, 5 por minuto por visitante, Relaxed, 20 por minuto por visitante, ou Strict, 2 a cada 10 minutos por visitante. O Standard também permite no máximo 3 a cada 10 segundos, e o formulário inteiro aceita 300 por minuto. Um formulário de contato combina com o Strict, já que ninguém honesto escreve duas vezes em dez minutos. Os números estão em Limites de taxa.
Allowed domains lista os sites onde seu formulário fica. Um envio de qualquer outra página é marcado como spam, ou recusado com 403 origin_not_allowed se você escolher Reject. Isso impede que outras pessoas apontem os formulários delas para o seu endpoint. Um script determinado pode falsificar o cabeçalho que é conferido, então trate isso como uma camada, não como uma tranca.
O que acontece com o spam
Nunca diga a um bot que ele foi pego. Um erro ensina o que mudar; uma página de sucesso normal não ensina nada. E nunca apague spam na chegada: todo filtro pega uma mensagem real de vez em quando, e uma apagada se perde.
Um envio julgado como spam recebe a mesma resposta de sucesso que um real, então o bot não aprende nada. Ele é guardado com o status spam, não dispara e-mail de notificação nem webhook, não conta no seu uso e é apagado após 30 dias. Toda segunda-feira, um workspace que bloqueou spam recebe um único e-mail de resumo com o total, os formulários mais visados e os principais motivos.
Abra o filtro de status do painel e escolha Spam para ver cada um com sua pontuação e seus motivos. Not spam o devolve, e o e-mail e os webhooks dele são disparados nesse momento. Desative o e-mail de segunda-feira em Weekly spam summary nas configurações do workspace. O spam fica guardado por 30 dias, então confira pelo menos uma vez por mês.
business_name conta como link num nome; renomeie o campo para business e ele deixa de contar como campo de nome.Escolha o seu caso
Cada uma destas páginas se aprofunda numa defesa ou num tipo de site, com código para esse caso.
- Campo honeypotAdicione 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.
- Cloudflare TurnstileAdicione 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.
- Sem captchaBarre 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 reCAPTCHACompare 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.
- Limite de taxaLimite a taxa de envios por visitante com um armazenamento compartilhado, responda 429 com Retry-After e evite bloquear escritórios que compartilham um IP.
- Sites estáticosAcabe com o spam num formulário de site estático mesmo sem guardar segredos nem contar envios, com um partial de honeypot, um script seguro e verificações no servidor.
- Spam em formulários do WebflowProteja um formulário do Webflow que envia a um backend de formulários, onde as opções de spam do Webflow nunca rodam, com um honeypot fora da tela e verificação humana.
- Next.jsProteja 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.
- AstroProteja um formulário Astro contra spam com uma Action: honeypot, Turnstile validado com segredos do astro:env, limite por IP e a CSP embutida do Astro.
- TanStack StartProteja um formulário TanStack Start contra spam: function middleware para o IP do cliente, token bucket, Turnstile validado numa server function e uma CSP.
- React RouterProteja um formulário React Router contra spam: action de rota com Turnstile, rate limit no Workers por cf-connecting-ip, um módulo .server e um cabeçalho de CSP.
- SvelteKitProteja um formulário SvelteKit contra spam: form action com getClientAddress, limite por IP, Turnstile com use:enhance e uma CSP que o permita.
- NuxtProteja 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.
Confira se funciona
Rode estes testes num formulário de teste antes de confiar nele. O Ikiform não tem plano gratuito. Testar é grátis: um workspace sem plano aceita 50 envios de teste por mês a partir de localhost. Para entrar no ar, escolha o Pro a partir de US$ 6/mês para 500 envios, ou pague uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram. Veja os preços.
- Em Allowed domains do formulário, adicione o domínio do seu site e também
localhostenquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar. - Preencha o formulário na sua página e envie. Você cai na sua página de agradecimento, ou na hospedada pelo Ikiform.
- Abra Submissions no painel. O novo envio está lá com todos os campos que você mandou.
- 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. - Envie uma mensagem com um link no campo de nome e a frase
seo servicesna mensagem. Ela é guardada como spam com os motivosurl_in_nameekeyword. - Mude Spam check para Ikiform e envie o formulário HTML simples. Você vê a página de verificação por um instante e depois sua página de agradecimento.
- Envie o formulário algumas vezes seguidas. Passado o limite, o formulário responde
429e o visitante vê a página do Ikiform pedindo para ir mais devagar. - Nada chegou? Mude o filtro de status do painel para Spam. O spam recebe a mesma resposta de sucesso que um envio real.
Solução de problemas
| Problema | O que fazer |
|---|---|
| O envio está no painel, mas nenhum e-mail ou webhook chegou | Ele foi guardado como spam. Spam não dispara nada. Abra-o no filtro Spam para ver os motivos e marque-o como não spam para disparar o e-mail e os webhooks. |
403 challenge_required | A verificação de spam do formulário está em Ikiform e um envio com fetch não tinha token. Obtenha um com challenge(formId) de @ikiform/sdk antes de cada envio, ou desative a verificação. |
403 origin_not_allowed, ou todo envio cai no spam | O domínio da página não está em Allowed domains do formulário. Adicione-o, e também localhost para testes locais. Subdomínios de um domínio listado são permitidos. |
429 rate_limited | Um visitante enviou mais do que o limite de taxa do formulário permite. O Standard permite 5 por minuto por visitante. Espere o Retry-After, ou escolha Relaxed ou Custom para formulários que enviam de um IP compartilhado. |
403 captcha_failed | O token do Turnstile estava ausente, expirado ou foi usado duas vezes. Tokens duram 300 segundos e funcionam uma vez, então renderize um widget novo, ou chame challenge() de novo, após cada tentativa. |
| O spam continua chegando como recebido | Abra um e veja o que ele tem em comum com os outros. Spam de script pede Spam check em Ikiform; ofertas digitadas pedem um limite de taxa mais rígido e uma revisão semanal. |
| Visitantes reais dizem que não conseguem enviar | Confira se o honeypot está oculto para leitores de tela e com um nome que o preenchimento automático ignora, e se um limite de taxa rígido não está pegando pessoas atrás de um mesmo IP de escritório. |
Perguntas
Camadas, não um truque só. Um honeypot oculto, um tempo mínimo para preencher o formulário, uma pontuação de conteúdo e um limite de taxa barram a maior parte do spam sem custo para visitantes reais. Adicione uma verificação humana como o Cloudflare Turnstile se o spam ainda passar, e valide o token dela num servidor.
No começo, não. A maior parte do spam em formulários de contato vem de bots simples que um honeypot e um limite de taxa barram. Adicione um captcha quando sua pasta de spam mostrar que bots ainda passam, e prefira um invisível como o Turnstile a quebra-cabeças de imagem.
Parte do spam é colada por pessoas pagas por formulário, geralmente com ofertas de SEO ou de desenvolvimento. Verificações de bot não barram uma pessoa. A pontuação de conteúdo pega muitas pelos links e frases; o resto você apaga na pasta de spam.
Não. O spam recebe a mesma resposta de sucesso que uma mensagem real, então os bots não aprendem nada. Ele fica guardado por 30 dias, não dispara e-mail nem webhook, e você pode marcá-lo como não spam no painel.
Não. Defina o Spam check do formulário como Ikiform e ele roda o Turnstile em ikiform.com sem chaves para gerenciar. Use suas próprias chaves do Turnstile só se quiser seu próprio widget na sua página.
Sim, se for escondido sem cuidado. Esconda-o fora da tela com aria-hidden no contêiner, tire-o da ordem de tabulação com tabindex -1 e dê a ele um nome que os navegadores não preencham automaticamente.
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.