Frena el spam en formularios de contacto7 min de lectura
Cómo frenar el spam en el formulario de contacto de un sitio estático
Frena 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.
Un sitio estático no tiene servidor, así que nada en la página puede guardar una clave secreta, verificar un token de captcha ni contar envíos por visitante. Esas comprobaciones tienen que ejecutarse en otro sitio: una función en tu host, o un backend de formularios al que envíe tu formulario. Pon un honeypot accesible en el formulario en cualquier caso, y mantén el formulario funcionando sin JavaScript.
Qué cambia en un sitio estático
En un sitio estático, cada byte que despliegas es público. Un bot puede leer y saltarse cualquier cosa de tu HTML o tu JavaScript, así que las comprobaciones antispam en el cliente son un consejo, no una imposición. Las tres comprobaciones que más importan necesitan un servidor:
- Verificar un token de captcha necesita una clave secreta, que no puede vivir en un archivo público.
- Contar envíos por visitante necesita un almacenamiento que persista entre peticiones.
- Leer un honeypot necesita código que se ejecute después de que llegue el envío, fuera del alcance del bot.
Los hosts estáticos tampoco aceptan envíos a tus páginas. Vercel y GitHub Pages responden a un POST de formulario a una página estática con 405 Method Not Allowed, así que el formulario necesita un endpoint antes siquiera de plantearse el spam.
- Una página estática que se envía a sí misma llega a un host que no ejecuta código, así que el envío falla con 405 o no va a ninguna parte.
- Con una función en el host, la página envía a una función que guarda el secreto, lee el honeypot y verifica el token, y luego reenvía el mensaje.
- Con un backend de formularios, la página envía directamente al backend, que ejecuta el honeypot, la puntuación, el límite de frecuencia y la verificación humana antes de enviarte el email.
Tus opciones
| Opción | Comprobaciones antispam | Contras |
|---|---|---|
| Netlify Forms | Akismet en cada envío, más un campo netlify-honeypot y reCAPTCHA v2 si los añades | Solo en Netlify. Cambiar de host significa rehacer el formulario. Consulta alternativas a Netlify Forms |
| Una función en tu host | Lo que escribas: honeypot, comprobación de tokens, un límite de frecuencia con un almacén | La escribes y la mantienes tú, y necesitas un servicio de email o de almacenamiento detrás |
| Un backend de formularios, como Ikiform | Honeypot, puntuación, límites de frecuencia, dominios permitidos y verificación humana, sin código | Otro servicio, con precio por envío |
El formulario, como partial reutilizable
Elijas el endpoint que elijas, escribe el formulario una sola vez como partial o include, para que todas las páginas usen el mismo honeypot. La versión de abajo envía a Ikiform. Para usar una función, cambia action por la ruta de la función y quita _redirect.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" class="contact">
<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></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>
<input type="hidden" name="_redirect" value="{{ "thanks/" | absURL }}" />
<p role="status"></p>
<button>Send message</button>
</form><style>
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>- El honeypot está fuera de pantalla, fuera del orden de tabulación y oculto para los lectores de pantalla. La guía del honeypot explica cada atributo.
_redirectlleva a la gente a tu página de agradecimiento. Ikiform solo lo sigue para una direcciónhttps:en uno de los Allowed domains del formulario.- Las guías de Hugo, Jekyll y Eleventy cubren la configuración, los shortcodes y GitHub Pages.
Mejóralo sin romperlo
Un script puede mantener a los visitantes en la página, pero nunca debe ser la única forma de que el formulario funcione. Este envía con fetch y recurre a un envío normal del navegador en dos casos: cuando falla la llamada de red y cuando la verificación humana de Ikiform pide un token con challenge_required. Un envío normal recibe en su lugar la página de verificación de Ikiform, así que puedes activar la verificación sin cambiar el script.
<script>
document.querySelectorAll("form.contact").forEach((form) => {
const status = form.querySelector("[role=status]");
const button = form.querySelector("button");
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true;
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
form.replaceChildren("Thanks. We'll reply by email.");
return;
}
if (result.error.code === "challenge_required") {
form.submit();
return;
}
const wait = response.headers.get("Retry-After");
if (response.status === 429 && wait) {
status.textContent = `Too many messages. Try again in ${wait} seconds.`;
} else {
status.textContent = result.error.message;
}
} catch {
form.submit();
return;
}
button.disabled = false;
});
});
</script>form.submit()envía el formulario sin volver a disparar el eventosubmit, así que el plan B no puede entrar en bucle.- Un
429muestra cuánto esperar a partir deRetry-After, que Ikiform expone al código del navegador. - La línea de éxito sustituye al formulario, para que nadie lo envíe dos veces por accidente.
Si escribes una función en el host
Netlify, Cloudflare Pages y Vercel ejecutan pequeñas funciones junto a los archivos estáticos. Una función es tu propio backend, así que se encarga de las comprobaciones antispam. Esta Netlify Function lee el honeypot, descarta los mensajes cargados de enlaces, verifica un token de Turnstile y te envía un email con Resend. Cada envío atrapado recibe la misma redirección de agradecimiento que uno real.
import type { Config, Context } from "@netlify/functions";
const LINK = /https?:\/\/|www\./gi;
export default async (req: Request, context: Context) => {
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
const form = await req.formData();
const email = String(form.get("email") ?? "");
const message = String(form.get("message") ?? "");
const thanks = Response.redirect(new URL("/thanks/", req.url), 303);
if (String(form.get("_gotcha") ?? "").trim()) return thanks;
if ((message.match(LINK)?.length ?? 0) >= 3) return thanks;
const check = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: new URLSearchParams({
secret: process.env.TURNSTILE_SECRET_KEY ?? "",
response: String(form.get("cf-turnstile-response") ?? ""),
remoteip: context.ip,
}),
});
const verdict = (await check.json()) as { success: boolean };
if (!verdict.success) return Response.redirect(new URL("/contact/?error=check", req.url), 303);
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
from: "Contact form <form@example.com>",
to: "you@example.com",
reply_to: email,
subject: "New message from the website",
text: message,
}),
});
return thanks;
};
export const config: Config = { path: "/api/contact" };context.ipes la IP del visitante, que siteverify puede usar comoremoteip.- Define
TURNSTILE_SECRET_KEYyRESEND_API_KEYen las variables de entorno del sitio, nunca en el repositorio. - Descarta los envíos atrapados en lugar de guardarlos, así que un mensaje real atrapado por error se pierde. Como mínimo, regístralos.
- No tiene límite de frecuencia, porque una función no tiene memoria entre llamadas. La guía de límites de frecuencia añade uno con Redis.
Qué hace Ikiform por un formulario estático
Con el formulario enviando directamente a Ikiform, el trabajo del lado del servidor ocurre allí. El honeypot es _gotcha, la puntuación del contenido y los límites de frecuencia por visitante se ejecutan en cada envío, y Allowed domains marca como spam los envíos de otros sitios. Pon Spam check en Ikiform para tener Cloudflare Turnstile sin claves: el envío HTML básico lo ejecuta en una página de verificación, y el script de arriba recurre a esa página. 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.
username.github.io. Los deploy previews de Netlify y las URLs de preview de Vercel no son subdominios de la dirección principal de tu sitio; añade el host exacto del preview mientras pruebes uno.Comprueba que funciona
- Desactiva JavaScript y envía. El formulario se envía igualmente y llegas a tu página de agradecimiento.
- Activa JavaScript y envía. El formulario se sustituye por la línea de agradecimiento.
- 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. - Pon Spam check en Ikiform y envía con el script activo. La página recurre a un envío normal y muestra la página de verificación.
- ¿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.
Trampas habituales
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áslocalhostpara las pruebas en local. Los subdominios de un dominio de la lista están permitidos.- HTML minificado. Algunos pasos de build eliminan atributos que creen vacíos o sin uso. Comprueba que la página generada sigue teniendo
aria-hiddenytabindexen el honeypot. - Páginas de agradecimiento en caché. Si la página de agradecimiento tiene la misma URL para el spam y los envíos reales, como debe ser, nunca pongas en ella nada que dependa de que el envío sea real.
- Claves en el repositorio. Un secreto subido al repositorio de un sitio estático público es público. Rótalo si alguna vez lo estuvo.
Preguntas
Puedes mostrar el widget, pero su token debe verificarse con una clave secreta en un servidor. Usa una función en tu host, un backend de formularios que verifique tus claves o la verificación gestionada de Ikiform, que no necesita claves.
Frena los bots más comunes, pero solo si algo en un servidor lo lee. Combínalo con un límite de frecuencia y la puntuación del contenido, que también necesitan un servidor o un backend de formularios.
El formulario envía a una página en un host que solo sirve archivos. Apunta el action del formulario a una función o al endpoint de un backend de formularios.
Debería. Envía primero con un formulario HTML básico, y añade un script que lo mejore y recurra a un envío normal cuando algo falle.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.