Guías18 min de lectura
Cómo frenar el spam en un formulario de contacto
Frena el spam en tu formulario de contacto con capas que no cuestan nada a las personas reales: honeypot, tiempo, puntuación del contenido, límites de frecuencia y verificación humana solo donde haga falta.
Apila comprobaciones baratas e invisibles antes de añadir fricción: un campo honeypot oculto, un tiempo mínimo para rellenar el formulario, una puntuación para enlaces y frases de spam, y un límite de frecuencia por visitante. Añade una verificación humana como Cloudflare Turnstile solo cuando el spam siga colándose, y verifica su token en un servidor. Guarda lo que atrapes en lugar de tirarlo, para poder rescatar algún mensaje real.
Un formulario de contacto en una página pública empieza a recibir basura a los pocos días de ser indexado. Casi nada va dirigido a ti. Hay scripts que recorren la web buscando cualquier <form>, rellenan todos los inputs y lo envían, con la esperanza de que algún enlace acabe publicado. Una parte menor viene de navegadores reales controlados por software, y otra de personas a las que se paga por pegar el mismo mensaje en miles de formularios. Cada tipo se frena con una comprobación distinta, y por eso ningún truco único lo soluciona.
Esta guía explica cada defensa, qué atrapa y qué les cuesta a los visitantes reales, con código que puedes usar en cualquier stack. Después muestra cómo Ikiform, un backend de formularios, aplica esas mismas capas por ti.
Capas, de la más barata a la más cara
Ordena las comprobaciones por lo que cuestan. Contar peticiones casi no cuesta nada, así que se hace antes de leer el cuerpo. Las comprobaciones que llaman a otro servicio, como un captcha, se ejecutan cuando pasan las baratas. La puntuación del contenido va al final, porque necesita los campos ya leídos. Ikiform ejecuta sus comprobaciones en este orden en cada envío.
- Llega un envío al endpoint del formulario.
- Primero, el límite de frecuencia cuenta los envíos por visitante. Un flood se rechaza con 429 antes de leer el cuerpo.
- Los dominios permitidos comparan la página de la que vino el envío con los dominios que indicaste.
- Si la verificación humana está activada, el envío debe llevar un token de Turnstile válido.
- El honeypot suma 5 a la puntuación cuando está relleno.
- El tiempo, los enlaces, las frases de spam conocidas, las direcciones desechables y las repeticiones suman cantidades menores.
- Con 5 o más, el envío se guarda como spam. Por debajo, el envío se recibe y salen tu email y tus webhooks.
Las defensas, comparadas
Toda defensa sacrifica algo. La tabla es honesta sobre lo que se le escapa a cada una y lo que exige a las personas de las que quieres saber.
| Defensa | Frena | Se le escapa | Coste para los visitantes reales |
|---|---|---|---|
| Honeypot | Scripts que rellenan todos los inputs | Bots que leen el CSS o se saltan los campos ocultos, y personas | Ninguno, si está oculto para lectores de pantalla y el autocompletado |
| Trampa de tiempo | Envíos más rápidos de lo que nadie puede escribir | Bots lentos y bots que esperan | Ninguno, siempre que el mínimo sea de unos pocos segundos |
| Puntuación del contenido, como conteo de enlaces, frases de spam o Akismet | Ofertas de SEO, enlaces sueltos, spam de casinos y pastillas | Spam de aspecto limpio, y puede marcar un mensaje real que tenga enlaces | Ninguno, si revisas lo que atrapa |
| Rate limiting | Floods y scripts en bucle | Spam lento y repartido | Oficinas y eventos detrás de una misma IP pueden alcanzar un límite estricto |
| Dominios permitidos | Envíos mandados directamente a tu endpoint desde otro lugar | Scripts que falsifican la cabecera Origin | Ninguno |
| Cloudflare Turnstile | La mayoría de los navegadores automatizados | Spammers humanos de pago | Normalmente invisible. A veces un clic, y necesita un script |
| reCAPTCHA o hCaptcha | La mayoría de los navegadores automatizados | Spammers humanos de pago y servicios que resuelven captchas | Rompecabezas de imágenes para algunos visitantes, más un script de terceros |
Empieza con las cinco primeras. Son invisibles y atrapan la mayor parte de lo que recibe un sitio pequeño. Añade una verificación humana cuando el panel te diga que el spam sigue colándose, no antes: es la única capa con un coste que todos los visitantes pueden notar. La página sobre frenar el spam sin captcha muestra hasta dónde llegan las capas invisibles por sí solas.
Lee el spam antes de combatirlo
Mira diez mensajes de spam antes de elegir una solución. Lo que tienen en común te dice quién los envía, y eso apunta a la capa que los frena.
- Texto aleatorio en todos los campos viene de un script que rellena todos los inputs. Un honeypot lo atrapa.
- Decenas de envíos por minuto desde una misma fuente vienen de un script en bucle. Un límite de frecuencia lo frena.
- Envíos limpios mandados menos de un segundo después de cargar la página vienen de un navegador headless. Una trampa de tiempo o una verificación humana lo frenan.
- Ofertas educadas con enlaces vienen de personas a las que se paga por formulario. Solo la puntuación del contenido y tu propia revisión las atrapan.
Un formulario de contacto con las capas integradas
Aquí tienes un mismo formulario de contacto, hecho para cada stack. Todas las versiones tienen el honeypot fuera de pantalla y una verificación humana. Las versiones solo de navegador usan la verificación Turnstile gestionada de Ikiform, que no necesita claves. Las versiones de servidor muestran la vía de hacerlo tú mismo: tu servidor verifica un token de Turnstile con tus propias claves de Cloudflare y luego reenvía los campos. Elige tu stack abajo; la elección se aplica a todos los ejemplos de código de estas guías.
Las pestañas de servidor comparten dos pequeños helpers. verifyTurnstile pregunta a Cloudflare si un token es real, y sendToIkiform reenvía los campos con tu clave secreta y la cabecera Origin del visitante. Un fetch del lado del servidor no envía un Origin propio, e Ikiform lo lee para reconocer una prueba gratuita desde localhost. Ambos fallan de forma segura: si no se puede contactar con Cloudflare, el visitante ve un error en lugar de pasar en silencio.
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." };
}
}El formulario básico no necesita ningún script. Pon el Spam check del formulario de Ikiform en Ikiform, y un envío sin token recibe una breve página de verificación en ikiform.com que ejecuta Turnstile y reenvía los campos. Quien la supera de forma invisible ni se entera.
<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>El honeypot queda fuera de pantalla en lugar de detrás de display: none, porque algunos bots se saltan los campos ocultos de esa forma. El aria-hidden del contenedor y el tabindex del input mantienen alejados a los lectores de pantalla y al teclado.
Una server action verifica el token de Turnstile con tus propias claves y luego reenvía los campos con tu clave secreta de Ikiform. El honeypot viaja intacto, así que uno relleno se guarda como spam donde puedes revisarlo. Guarda los helpers de arriba como lib/spam.ts. Define TURNSTILE_SECRET_KEY e IKIFORM_SECRET_KEY como variables de entorno solo de servidor, y deja el Spam check del formulario de Ikiform en Off: tu servidor ya hizo la verificación 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} />;
}El widget se renderiza de forma explícita y se vuelve a renderizar cada vez que la action responde, porque un token de Turnstile solo sirve una vez. defaultValue devuelve al visitante su texto tras un error, ya que React reinicia el formulario después de ejecutar su action.
¿Quieres profundizar con Next.js? Lee Protección antispam para formularios de Next.js con Turnstile.
Una Astro Action valida los campos con Zod, comprueba el token con context.clientAddress y reenvía el envío. Guarda los helpers de arriba como src/lib/spam.ts y lee ambas claves con getSecret de astro:env/server. El formulario se envía sin JavaScript en el cliente, así que cada intento recarga la página y el widget se renderiza de nuevo. Añade un adapter y mantén prerender = false. Define TURNSTILE_SECRET_KEY e IKIFORM_SECRET_KEY como variables de entorno solo de servidor, y deja el Spam check del formulario de Ikiform en Off: tu servidor ya hizo la verificación 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>¿Quieres profundizar con Astro? Lee Protección antispam para formularios de Astro con Turnstile.
Una función POST con createServerFn lee la IP del visitante con getRequestHeader, verifica el token y reenvía los campos. El componente del widget es el mismo que en la pestaña de Next.js, en src/components/turnstile.tsx. Define TURNSTILE_SECRET_KEY e IKIFORM_SECRET_KEY como variables de entorno solo de servidor, y deja el Spam check del formulario de Ikiform en Off: tu servidor ya hizo la verificación 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>
);
}¿Quieres profundizar con TanStack Start? Lee Protección antispam para formularios de TanStack Start con Turnstile.
La action de la ruta verifica el token y reenvía el envío. Pasar actionData como reset renderiza un widget nuevo después de cada respuesta. El componente del widget es el de la pestaña de Next.js. Define TURNSTILE_SECRET_KEY e IKIFORM_SECRET_KEY como variables de entorno solo de servidor, y deja el Spam check del formulario de Ikiform en Off: tu servidor ya hizo la verificación 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>
);
}¿Quieres profundizar con React Router? Lee Protección antispam para formularios de React Router con Turnstile.
Una form action por defecto verifica el token con getClientAddress() y reenvía el envío. Esta versión omite use:enhance a propósito: cada intento recarga la página, así que Turnstile renderiza un widget nuevo con un token nuevo. Guarda los helpers de arriba como src/lib/spam.ts. Declara las claves en src/env.ts con defineEnvVars y dale a PUBLIC_TURNSTILE_SITE_KEY public: true, para que la página pueda importarla desde $app/env/public. Define TURNSTILE_SECRET_KEY e IKIFORM_SECRET_KEY como variables de entorno solo de servidor, y deja el Spam check del formulario de Ikiform en Off: tu servidor ya hizo la verificación 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}¿Quieres profundizar con SvelteKit? Lee Protección antispam para formularios de SvelteKit con Turnstile.
Una ruta de servidor verifica el token y reenvía el envío. Guarda los helpers de arriba como server/utils/spam.ts y Nuxt los importa automáticamente. El componente renderiza Turnstile de forma explícita y lo reinicia tras un intento fallido. Define TURNSTILE_SECRET_KEY e IKIFORM_SECRET_KEY como variables de entorno solo de servidor, y deja el Spam check del formulario de Ikiform en Off: tu servidor ya hizo la verificación 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>¿Quieres profundizar con Nuxt? Lee Protección antispam para formularios de Nuxt con Turnstile.
Una single-page app no tiene un servidor donde guardar un secreto de Turnstile, así que usa en su lugar la verificación gestionada de Ikiform. challenge() de @ikiform/sdk monta un pequeño frame de ikiform.com que permanece invisible salvo que Turnstile necesite un clic, y devuelve un token para submit(). Pon Spam check en Ikiform y añade tu dominio en 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>
);
}La misma verificación gestionada en un componente de un solo archivo de Vue. El frame aparece dentro del div vacío solo cuando un visitante tiene que hacer clic.
<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>- Todas las versiones usan la clase
.hpdel bloque de estilos de la pestaña HTML. Ponla en tu CSS global. - El honeypot se llama
_gotcha, el nombre por defecto de Ikiform. Las versiones de servidor lo reenvían intacto, así que uno relleno sigue llegando a tu carpeta de spam con el motivohoneypot. - Turnstile añade al formulario un input oculto
cf-turnstile-response. Un token es válido durante 300 segundos y solo sirve una vez, así que cada intento necesita uno nuevo. - Las claves de prueba de Cloudflare te permiten ejecutar todo esto en local. La guía de Turnstile las enumera.
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.Prueba la definición de campos de abajo. Ejecuta el validador de Ikiform en tu navegador, con las mismas reglas que aplica el servidor antes de calcular cualquier puntuación de spam.
Vista previa
Ejecuta el validador de Ikiform en tu navegador. No se envía nada.
422 validation_failed los envíos que no coincidan.El honeypot
Un honeypot es un input que las personas nunca ven y que los bots rellenan. Si tiene un valor, el envío vino de un software. No les cuesta nada a los visitantes y atrapa los bots más antiguos y comunes, los que leen el HTML y rellenan todos los inputs que encuentran.
Ocultarlo bien importa más que el truco en sí. Con los honeypots descuidados fallan tres cosas:
- Los lectores de pantalla lo anuncian. Un visitante ciego oye un campo extra, lo rellena y queda marcado como spam. Envuélvelo en
aria-hidden="true"y dale una etiqueta que diga que se deje vacío. - El teclado llega hasta él. Al tabular por el formulario, el foco se detiene en un input invisible.
tabindex="-1"lo saca del orden de tabulación. - El autocompletado lo rellena. Los navegadores y los gestores de contraseñas rellenan campos por su nombre. Si llamas al campo
email2ophone, algunos lo rellenarán para personas reales. Usa un nombre que ningún autocompletado reconozca, como_gotcha, y añadeautocomplete="off".
La guía del honeypot cubre técnicas para ocultarlo, cómo lo detectan los bots y la comprobación en el servidor para cada stack. Con Ikiform, un honeypot relleno suma 5 a la puntuación, lo que alcanza por sí solo el umbral de 5. Puedes renombrar el campo en cada formulario en Honeypot field.
Tiempo
Las personas tardan segundos en rellenar un formulario de contacto. Muchos bots tardan milisegundos. Una trampa de tiempo registra cuándo se renderizó el formulario y comprueba el intervalo cuando se envía. La versión ingenua pone una marca de tiempo en un campo oculto:
const started = Number(formData.get("started"));
const seconds = (Date.now() - started) / 1000;
const tooFast = !Number.isFinite(seconds) || seconds < 3;Un bot puede enviar el número que quiera, así que una marca de tiempo sin más solo frena a los bots que no se molestan. Fírmala en el servidor con un secreto y el bot no podrá fingir un renderizado más lento. La página sobre frenar el spam sin captcha construye la versión firmada.
Ikiform firma las horas de renderizado por ti cuando el formulario viene del SDK o del componente de shadcn/ui: envían _ikiform_ts, y un envío hecho menos de 2 segundos después del renderizado suma 3 a la puntuación. Una marca de tiempo falsificada o de hace un día suma 3. Los formularios HTML básicos no envían ninguna y no se penalizan.
Puntuar el contenido
Parte del spam lo escriben o pegan personas, así que supera todas las comprobaciones contra bots. Lo que lo delata es el contenido: enlaces, frases de venta, direcciones desechables y el mismo texto dos veces. Puntúa cada señal y trata un total alto como spam, en lugar de bloquear por una sola. Un enlace en un mensaje es normal; un enlace en el campo del nombre nunca lo es.
| Señal | Ikiform suma |
|---|---|
| El honeypot está relleno | +5 |
| Un enlace en un campo de nombre o asunto | +3 |
| Los mismos campos enviados a este formulario en las últimas 24 horas | +3 |
| Enviado menos de 2 segundos después de la hora de renderizado firmada | +3 |
| Tres o más enlaces entre todos los campos | +2 |
| Una de las 22 frases de spam conocidas, como "seo services" | +2 |
| Una dirección de un dominio de email desechable | +2 |
Con 5 o más, el envío es spam. Ninguna señal leve llega sola: un mensaje real con tres enlaces puntúa 2. Dos juntas a menudo sí, como un enlace en el nombre y una frase de spam. Si puntúas el contenido tú mismo, Akismet es la opción alojada de toda la vida; muchos equipos empiezan con unas pocas expresiones regulares como las de arriba.
Una verificación humana
Una verificación humana pide al navegador que demuestre que lo maneja una persona. Las modernas, como Cloudflare Turnstile, funcionan casi siempre de forma invisible y solo muestran una casilla cuando tienen dudas. El widget le da un token al navegador; tu servidor debe enviar ese token al proveedor para verificarlo. Un token que nunca se verifica en un servidor no frena nada, porque un bot puede saltarse el widget y enviar sin él.
Ikiform tiene tres ajustes en Spam check en cada formulario:
| Ajuste | Qué pasa | Cambios en el código |
|---|---|---|
| Off (the default) | Sin desafío. El honeypot, el tiempo y la puntuación siguen funcionando. | Ninguno |
| Ikiform | Turnstile ejecutado por Ikiform en ikiform.com, sin cuenta ni claves de Cloudflare | Ninguno para envíos HTML. Los envíos con fetch llaman primero a challenge() de @ikiform/sdk |
| Your own Turnstile keys (tus propias claves de Turnstile) | Tu widget y tu clave secreta. Ikiform verifica el token y compara su hostname con tus dominios permitidos | Renderiza el widget en tu formulario para que envíe cf-turnstile-response |
Un envío con fetch sin token recibe 403 challenge_required. Si no se puede contactar con Cloudflare, Ikiform conserva el envío y lo guarda como spam con el motivo captcha_unavailable, para que puedas restaurarlo. La documentación de spam muestra ambos flujos. Si estás comparando Turnstile con reCAPTCHA de Google o hCaptcha, consulta alternativas a reCAPTCHA.
Límites de frecuencia y dominios permitidos
Un límite de frecuencia acota cuántas veces puede enviar un mismo visitante. No distingue el spam del correo legítimo; frena el volumen. Cuenta por IP y formulario, permite una pequeña ráfaga y responde 429 con una cabecera Retry-After. La guía de límites de frecuencia construye uno para cada stack y explica por qué un contador en memoria falla en hosts serverless.
Ikiform cuenta cada envío con exactitud, antes de leer el cuerpo. Cada formulario elige un preset: Standard, 5 por minuto por visitante; Relaxed, 20 por minuto por visitante; o Strict, 2 cada 10 minutos por visitante. Standard además permite como máximo 3 cada 10 segundos, y el formulario entero admite 300 por minuto. A un formulario de contacto le va bien Strict, porque nadie honesto escribe dos veces en diez minutos. Los números están en Límites de frecuencia.
Allowed domains (dominios permitidos) enumera los sitios donde vive tu formulario. Un envío desde cualquier otra página se marca como spam, o se rechaza con 403 origin_not_allowed si eliges Reject. Impide que otros apunten sus propios formularios a tu endpoint. Un script decidido puede falsificar la cabecera que comprueba, así que trátalo como una capa más, no como un candado.
Qué pasa con el spam
Nunca le digas a un bot que lo has atrapado. Un error le enseña qué cambiar; una página de éxito normal no le enseña nada. Y nunca borres el spam al llegar: todo filtro atrapa de vez en cuando un mensaje real, y uno borrado se pierde para siempre.
Un envío considerado spam recibe la misma respuesta de éxito que uno real, así que el bot no aprende nada. Se guarda con el estado spam, no envía email de notificación ni webhook, no cuenta para tu uso y se elimina a los 30 días. Cada lunes, un espacio de trabajo que haya bloqueado spam recibe un email de resumen con el total, los formularios con más actividad y los motivos principales.
Abre el filtro de estado del panel y elige Spam para ver cada envío con su puntuación y sus motivos. Not spam lo devuelve a recibidos, y en ese momento salen su email y sus webhooks. Desactiva el email de los lunes en Weekly spam summary en los ajustes del espacio de trabajo. El spam se conserva 30 días, así que revísalo al menos una vez al mes.
business_name puntúa como enlace en un nombre; renombra el campo a business y dejará de contar como campo de nombre.Elige tu caso
Cada una de estas páginas profundiza en una defensa o en un tipo de sitio, con código para ese caso.
- Campo honeypotAñade a cualquier formulario un honeypot que atrape bots sin molestar a lectores de pantalla, teclado ni autocompletado, con la comprobación en el servidor para cada stack.
- Cloudflare TurnstileAñade Cloudflare Turnstile a un formulario HTML básico: el widget, siteverify en el servidor, claves de prueba y una forma de usarlo sin claves ni servidor propios.
- Sin captchaFrena casi todo el spam de tus formularios sin ningún captcha: honeypot, hora de renderizado firmada, puntuación del contenido y límite de frecuencia, sumados en lugar de usarlos por separado.
- Alternativas a reCAPTCHACompara alternativas a reCAPTCHA para formularios, de Turnstile y hCaptcha a proof-of-work o ningún captcha, y cambia con un ajuste pequeño y seguro.
- Límites de frecuenciaLimita los envíos de un formulario por visitante con un almacén compartido, responde 429 con Retry-After y no bloquees oficinas que comparten una IP.
- Sitios estáticosFrena el spam en el formulario de un sitio estático aunque no pueda guardar secretos ni contar envíos, con un partial de honeypot, un script seguro y comprobaciones en el servidor.
- Spam en formularios de WebflowProtege un formulario de Webflow que envía a un backend de formularios, donde los ajustes antispam de Webflow nunca se ejecutan, con un honeypot fuera de pantalla y una verificación humana.
- Next.jsProtege un formulario de Next.js del spam: un honeypot, Turnstile verificado en una server action, un límite por IP en Vercel, Netlify o Cloudflare, y su CSP.
- AstroProtege un formulario de Astro del spam con una Action: un honeypot, Turnstile verificado con secretos de astro:env, un límite por IP y la CSP integrada de Astro.
- TanStack StartProtege un formulario de TanStack Start del spam: middleware de función para la IP del cliente, un token bucket, Turnstile verificado en una server function y una CSP.
- React RouterProtege un formulario de React Router del spam: una action de ruta con Turnstile, un límite en Workers basado en cf-connecting-ip, un módulo .server y una cabecera CSP.
- SvelteKitProtege un formulario de SvelteKit del spam: una form action con getClientAddress, un límite por IP, Turnstile con use:enhance y una CSP que lo permite.
- NuxtProtege un formulario de Nuxt del spam: una server route de Nitro con Turnstile, un límite por IP en Nitro storage, secretos en runtimeConfig y una CSP en routeRules.
Comprueba que funciona
Haz estas pruebas con un formulario de prueba antes de confiar en él. Ikiform no tiene plan gratuito. Probar es gratis: un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desde localhost. Para pasar a producción, elige Pro desde 6 $/mes por 500 envíos, o paga una sola vez: 10.000 envíos por 49 $ o 50.000 envíos por 149 $, con créditos que nunca caducan. Consulta los precios.
- En Allowed domains del formulario, añade el dominio de tu sitio, más
localhostmientras desarrollas. Déjalo vacío para aceptar envíos desde cualquier lugar. - Rellena el formulario en tu página y envíalo. Llegas a tu página de agradecimiento, o a la página alojada de Ikiform.
- Abre Submissions en el panel. El nuevo envío está ahí con todos los campos que mandaste.
- Envía con el honeypot relleno,
_gotcha=bot. Sigue recibiendo una respuesta de éxito, pero el envío se guarda como spam y no envía email ni webhook. - Envía un mensaje con un enlace en el campo del nombre y la frase
seo servicesen el mensaje. Se guarda como spam con los motivosurl_in_nameykeyword. - Cambia Spam check a Ikiform y envía el formulario HTML básico. Ves la página de verificación un momento y luego tu página de agradecimiento.
- Envía el formulario varias veces seguidas. Pasado el límite, el formulario responde
429y el visitante ve la página de Ikiform que le pide ir más despacio. - ¿No llegó nada? Cambia el filtro de estado del panel a Spam. El spam recibe la misma respuesta de éxito que un envío real.
Solución de problemas
| Problema | Qué hacer |
|---|---|
| El envío está en el panel pero no llegó ningún email ni webhook | Se guardó como spam. El spam no envía nada. Ábrelo en el filtro Spam para ver los motivos, y márcalo como no spam para que salgan su email y sus webhooks. |
403 challenge_required | La verificación antispam del formulario está en Ikiform y un envío con fetch no llevaba token. Obtén uno con challenge(formId) de @ikiform/sdk antes de cada envío, o desactiva la verificación. |
403 origin_not_allowed, o todos los envíos acaban en spam | El dominio de la página no está en Allowed domains del formulario. Añádelo, más localhost para las pruebas en local. Los subdominios de un dominio de la lista están permitidos. |
429 rate_limited | Un visitante envió más de lo que permite el límite de frecuencia del formulario. Standard permite 5 por minuto por visitante. Espera lo que indique Retry-After, o elige Relaxed o Custom para formularios que envían desde una IP compartida. |
403 captcha_failed | Al token de Turnstile le faltaba algo: no estaba, había caducado o se usó dos veces. Los tokens duran 300 segundos y sirven una sola vez, así que renderiza un widget nuevo, o vuelve a llamar a challenge(), después de cada intento. |
| El spam sigue llegando como recibido | Abre uno y fíjate en lo que tiene en común con el resto. El spam automatizado pide poner Spam check en Ikiform; las ofertas escritas a mano piden un límite de frecuencia más estricto y una revisión semanal. |
| Visitantes reales dicen que no pueden enviar | Comprueba que el honeypot está oculto para los lectores de pantalla y tiene un nombre que el autocompletado ignora, y que un límite de frecuencia estricto no está atrapando a personas detrás de la misma IP de oficina. |
Preguntas
Capas, no un único truco. Un honeypot oculto, un tiempo mínimo para rellenar el formulario, una puntuación del contenido y un límite de frecuencia frenan la mayor parte del spam sin coste para los visitantes reales. Añade una verificación humana como Cloudflare Turnstile si el spam sigue colándose, y verifica su token en un servidor.
Al principio no. La mayor parte del spam en formularios de contacto viene de bots sencillos que un honeypot y un límite de frecuencia frenan. Añade un captcha cuando tu carpeta de spam muestre que siguen colándose bots, y elige uno invisible como Turnstile antes que los rompecabezas de imágenes.
Parte del spam lo pegan personas a las que se paga por formulario, normalmente ofertas de SEO o de desarrollo. Las comprobaciones contra bots no pueden frenar a una persona. La puntuación del contenido atrapa muchos mensajes por sus enlaces y frases; el resto lo borras en la carpeta de spam.
No. El spam recibe la misma respuesta de éxito que un mensaje real, así que los bots no aprenden nada. Se guarda 30 días, no envía email ni webhook, y puedes marcarlo como no spam desde el panel.
No. Pon el Spam check del formulario en Ikiform y ejecutará Turnstile en ikiform.com sin claves que gestionar. Usa tus propias claves de Turnstile solo si quieres tu propio widget en tu página.
Sí, si se oculta sin cuidado. Ocúltalo fuera de pantalla con aria-hidden en su contenedor, sácalo del orden de tabulación con tabindex -1 y dale un nombre que los navegadores no autocompleten.
No, no hay plan gratuito. Probar es gratis: puedes enviar 50 envíos de prueba al mes desde localhost antes de pagar. Para pasar a producción, Pro empieza en 6 $/mes por 500 envíos, o puedes pagar una sola vez 49 $ por 10.000 envíos o 149 $ por 50.000 envíos, con créditos que nunca caducan.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.