Frena el spam en formularios de contacto

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.

Preet SutharFundador, Ikiform

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.

Tres formas de conectar el formulario de un sitio estático, y dónde se ejecutan las comprobaciones antispam en cada unaSolo la páginaCon una función en el hostCon un backend de formulariospágina estáticase envía a sí mismahostno se ejecuta código405, o perdidopágina estáticafunciónsecreto, honeypot, tokentu email o almacénpágina estáticabackend deformularioshoneypot, puntuación,límite, Turnstilebandeja y webhooksTres formas de conectar el formulario de un sitio estático, y dónde se ejecutan las comprobaciones antispam en cada unaSolo la páginaCon una función en el hostCon un backend de formulariospágina estáticase envía a sí mismahostno se ejecuta código405, o perdidopágina estáticafunciónsecreto, honeypot, tokentu email o almacénpágina estáticabackend de formularioshoneypot, puntuación, límite,Turnstilebandeja y webhooks
  1. 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.
  2. 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.
  3. 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.
En las dos filas que funcionan, las comprobaciones se ejecutan después de que el envío sale del navegador. Es el único lugar donde un bot no puede saltárselas.

Tus opciones

OpciónComprobaciones antispamContras
Netlify FormsAkismet en cada envío, más un campo netlify-honeypot y reCAPTCHA v2 si los añadesSolo en Netlify. Cambiar de host significa rehacer el formulario. Consulta alternativas a Netlify Forms
Una función en tu hostLo que escribas: honeypot, comprobación de tokens, un límite de frecuencia con un almacénLa escribes y la mantienes tú, y necesitas un servicio de email o de almacenamiento detrás
Un backend de formularios, como IkiformHoneypot, puntuación, límites de frecuencia, dominios permitidos y verificación humana, sin códigoOtro 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>
assets/contact.css
<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.
  • _redirect lleva a la gente a tu página de agradecimiento. Ikiform solo lo sigue para una dirección https: 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.

contact-form.html
<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 evento submit, así que el plan B no puede entrar en bucle.
  • Un 429 muestra cuánto esperar a partir de Retry-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.

netlify/functions/contact.mts
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.ip es la IP del visitante, que siteverify puede usar como remoteip.
  • Define TURNSTILE_SECRET_KEY y RESEND_API_KEY en 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.

Indica en Allowed domains cada host donde está publicado el formulario: tu dominio, más la dirección propia del host mientras pruebas, como 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

  1. Desactiva JavaScript y envía. El formulario se envía igualmente y llegas a tu página de agradecimiento.
  2. Activa JavaScript y envía. El formulario se sustituye por la línea de agradecimiento.
  3. 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.
  4. 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.
  5. ¿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ás localhost para 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-hidden y tabindex en 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

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.