Guías

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.

Preet SutharFundador, Ikiform

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.

El orden de las comprobaciones antispam, desde que llega un envío hasta que se guarda o se marca como spamRechazado de entradaSe puntúa y se guarda en ambos casosllega unenvíode una página,un script o unservidorlímite defrecuenciademasiados deun visitante:429dominiospermitidos¿enviadodesde otrositio?verificaciónhumanatoken deTurnstile, siestá activahoneypotrelleno: +5tiempo ycontenidomuy rápido,enlaces,frases,repeticiones¿puntuaciónde 5 omás?recibidosalen elemail y loswebhooksspammismarespuesta deéxito, no seenvía nadaconteoorigentokennosíEl orden de las comprobaciones antispam, desde que llega un envío hasta que se guarda o se marca como spamRechazado de entradaSe puntúa y se guarda en ambos casosllega un envíode una página, unscript o un servidorlímite defrecuenciademasiados de unvisitante: 429dominiospermitidos¿enviado desde otrositio?verificaciónhumanatoken de Turnstile,si está activahoneypotrelleno: +5tiempo ycontenidomuy rápido, enlaces,frases, repeticiones¿puntuaciónde 5 o más?recibidosalen el email y loswebhooksspammismarespuestade éxito,no se envíanadaconteoorigentokennosí
  1. Llega un envío al endpoint del formulario.
  2. Primero, el límite de frecuencia cuenta los envíos por visitante. Un flood se rechaza con 429 antes de leer el cuerpo.
  3. Los dominios permitidos comparan la página de la que vino el envío con los dominios que indicaste.
  4. Si la verificación humana está activada, el envío debe llevar un token de Turnstile válido.
  5. El honeypot suma 5 a la puntuación cuando está relleno.
  6. El tiempo, los enlaces, las frases de spam conocidas, las direcciones desechables y las repeticiones suman cantidades menores.
  7. 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.
La fila de arriba rechaza envíos. La de abajo nunca rechaza: puntúa, así que un falso positivo se arregla con un clic para restaurarlo, no es un mensaje perdido.

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.

DefensaFrenaSe le escapaCoste para los visitantes reales
HoneypotScripts que rellenan todos los inputsBots que leen el CSS o se saltan los campos ocultos, y personasNinguno, si está oculto para lectores de pantalla y el autocompletado
Trampa de tiempoEnvíos más rápidos de lo que nadie puede escribirBots lentos y bots que esperanNinguno, siempre que el mínimo sea de unos pocos segundos
Puntuación del contenido, como conteo de enlaces, frases de spam o AkismetOfertas de SEO, enlaces sueltos, spam de casinos y pastillasSpam de aspecto limpio, y puede marcar un mensaje real que tenga enlacesNinguno, si revisas lo que atrapa
Rate limitingFloods y scripts en bucleSpam lento y repartidoOficinas y eventos detrás de una misma IP pueden alcanzar un límite estricto
Dominios permitidosEnvíos mandados directamente a tu endpoint desde otro lugarScripts que falsifican la cabecera OriginNinguno
Cloudflare TurnstileLa mayoría de los navegadores automatizadosSpammers humanos de pagoNormalmente invisible. A veces un clic, y necesita un script
reCAPTCHA o hCaptchaLa mayoría de los navegadores automatizadosSpammers humanos de pago y servicios que resuelven captchasRompecabezas 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.

Del aspecto del spam a la capa que lo frenatexto aleatorio entodos los camposun bot de scriptlee el HTML, rellenatodos los inputshoneypotdecenas por minuto,una fuenteun script en buclelímite de frecuenciaenvíos limpios alsegundo de cargarun navegadorheadlesstrampa de tiempo,verificación humanaofertas educadas conenlacesuna persona, pagadapor formulariopuntuación, revisiónLo que vesQuién lo envíaQué lo frenaDel aspecto del spam a la capa que lo frenatexto aleatorio en todoslos camposun bot de scriptlee el HTML, rellena todos losinputshoneypotdecenas por minuto, unafuenteun script en buclelímite de frecuenciaenvíos limpios al segundode cargarun navegador headlesstrampa de tiempo,verificación humanaofertas educadas conenlacesuna persona, pagada porformulariopuntuación, revisión
  1. Texto aleatorio en todos los campos viene de un script que rellena todos los inputs. Un honeypot lo atrapa.
  2. Decenas de envíos por minuto desde una misma fuente vienen de un script en bucle. Un límite de frecuencia lo frena.
  3. 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.
  4. 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.
Ningún captcha frena la última fila. Una persona puede resolverlo, así que la puntuación del contenido y un vistazo semanal a la carpeta de spam son la única defensa.

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.

lib/spam.ts
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.

contact.html
<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.

  • Todas las versiones usan la clase .hp del 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 motivo honeypot.
  • 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.
Las versiones de servidor envían con una clave secreta, así que Ikiform se salta Allowed domains y su verificación humana. Todos los envíos llegan entonces desde la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el límite de frecuencia Relaxed o Custom para un formulario que envías a través de un servidor, y guarda 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.

Los mismos campos que la plantilla de formulario de contacto. Pega la definición en la pestaña Fields del formulario e Ikiform rechazará con 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 email2 o phone, algunos lo rellenarán para personas reales. Usa un nombre que ningún autocompletado reconozca, como _gotcha, y añade autocomplete="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:

Trampa de tiempo, la versión ingenua
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ñalIkiform 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:

AjusteQué pasaCambios en el código
Off (the default)Sin desafío. El honeypot, el tiempo y la puntuación siguen funcionando.Ninguno
IkiformTurnstile ejecutado por Ikiform en ikiform.com, sin cuenta ni claves de CloudflareNinguno 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 permitidosRenderiza 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.

Si un mensaje real sigue cayendo en la trampa, lee sus motivos. Un enlace en un campo llamado 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.

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.

  1. En Allowed domains del formulario, añade el dominio de tu sitio, más localhost mientras desarrollas. Déjalo vacío para aceptar envíos desde cualquier lugar.
  2. 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.
  3. Abre Submissions en el panel. El nuevo envío está ahí con todos los campos que mandaste.
  4. 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.
  5. Envía un mensaje con un enlace en el campo del nombre y la frase seo services en el mensaje. Se guarda como spam con los motivos url_in_name y keyword.
  6. 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.
  7. Envía el formulario varias veces seguidas. Pasado el límite, el formulario responde 429 y el visitante ve la página de Ikiform que le pide ir más despacio.
  8. ¿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

ProblemaQué hacer
El envío está en el panel pero no llegó ningún email ni webhookSe 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_requiredLa 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 spamEl 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_limitedUn 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_failedAl 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 recibidoAbre 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 enviarComprueba 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

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