↑ top
← Back to blog
EN | ES

Protección Anti-Spam Sin CAPTCHAs

Cómo detener el spam en formularios de contacto silenciosamente usando un campo honeypot y rate limiting en D1 — sin CAPTCHA, sin scripts de terceros, sin falsos positivos.

✦ AI Summary

ReCAPTCHA es un producto de Google que les pide a los usuarios reales identificar semáforos mientras no hace nada contra atacantes determinados. Existe una mejor forma que no requiere dependencias externas ni castiga a usuario legítimos.

Protección Anti-Spam Sin CAPTCHAs

Todo formulario de contacto en internet eventualmente recibe spam. La respuesta estándar es reCAPTCHA — un producto de Google que les pide a los usuarios reales identificar semáforos mientras no hace nada contra atacantes determinados. Existe una mejor forma que no requiere dependencias externas ni castiga a los usuarios legítimos.

Este es el setup exacto que corre en el endpoint /contact de este sitio: dos capas independientes que retornan { ok: true } cuando se activan, sin darle a los bots ninguna señal de que fueron bloqueados.

Por qué no CAPTCHA

reCAPTCHA tiene tres problemas:

  1. Carga JavaScript de terceros desde google.com, lo que rompe CSP, agrega latencia y envía el comportamiento de tus visitantes a Google.
  2. Crea fricción para usuarios reales. Cada vez que alguien tiene que resolver un puzzle para contactarte, un porcentaje cierra la pestaña.
  3. No detiene spam sofisticado. Las granjas de solución de CAPTCHA cuestan aproximadamente $2 por 1,000 soluciones. Los spammers las usan.

El objetivo no es un muro impenetrable — es detener el spam automatizado de bajo esfuerzo (que es el 95% de lo que verás) sin afectar la experiencia de visitantes legítimos.

Capa 1: Campo honeypot

Un honeypot es un campo de formulario invisible para usuarios reales pero que los bots que auto-completan formularios llenan. El concepto es simple: si este campo oculto tiene un valor, el envío es de un bot.

html<input
  class="contact-honeypot"
  type="text"
  name="website"
  tabindex="-1"
  autocomplete="off"
  aria-hidden="true"
/>

El CSS lo mantiene completamente fuera de la pantalla:

css.contact-honeypot {
  position: absolute;
  left: -9999px;
  opacity: 0;
  pointer-events: none;
  height: 0;
  width: 0;
  overflow: hidden;
}

Algunos detalles importan aquí. El campo se llama website — algo que un bot plausiblemente querría llenar. tabindex="-1" lo elimina de la navegación por teclado para que lectores de pantalla y usuarios de teclado no puedan alcanzarlo accidentalmente. aria-hidden="true" lo oculta de la tecnología asistiva.

La verificación en el servidor es una sola condición, evaluada antes de cualquier operación de base de datos o email:

jsconst { name, email, message, type, website } = await request.json();

// Honeypot — los bots lo llenan, los humanos no. Retornar ok silenciosamente.
if (website) {
  return Response.json({ ok: true });
}

Retornar { ok: true } es intencional. Si retornas un error, los scrapers aprenden que el campo existe y se adaptan. El silencio es más efectivo.

Capa 2: Rate limiting por IP vía D1

El honeypot detiene bots simples. El rate limiting detiene cualquier cosa que pase esa primera capa. Un envío por IP por hora es suficiente para hacer el spam masivo impracticable sin afectar nunca a usuarios reales.

La implementación reutiliza la tabla reaction_ratelimits que ya existe en la base de datos D1 del sitio — la misma tabla que limita las reacciones emoji en los posts. No se necesitan cambios de esquema.

sqlCREATE TABLE IF NOT EXISTS reaction_ratelimits (
  key TEXT PRIMARY KEY,   -- "contact:{ip}" para el formulario, "ip:slug:emoji" para reacciones
  expires_at INTEGER NOT NULL  -- Timestamp Unix
);

La lógica de verificación y escritura corre después del honeypot:

jsconst ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
const rlKey = `contact:${ip}`;
const now = Math.floor(Date.now() / 1000);

// Verificar si esta IP está limitada
const existing = await env.DB
  .prepare("SELECT expires_at FROM reaction_ratelimits WHERE key = ?")
  .bind(rlKey)
  .first();

if (existing && existing.expires_at > now) {
  return Response.json({ ok: true }); // Bloqueo silencioso
}

// Establecer o actualizar la ventana de rate limit (1 hora)
await env.DB
  .prepare(`
    INSERT INTO reaction_ratelimits (key, expires_at)
    VALUES (?, ?)
    ON CONFLICT(key) DO UPDATE SET expires_at = excluded.expires_at
  `)
  .bind(rlKey, now + 3600)
  .run();

CF-Connecting-IP es un header que Cloudflare agrega a cada request con la IP real del cliente — sin headers de proxy que falsificar, sin necesidad de parsear X-Forwarded-For.

La escritura del rate limit ocurre antes de enviar el email, no después. Si D1 no está disponible, el try/catch alrededor de initDb() significa que el envío pasa — fallar abierto es el default correcto aquí. Preferís recibir un duplicado antes que perder silenciosamente un mensaje legítimo.

Por qué ambas capas

Cada capa falla de manera diferente:

  • El honeypot falla contra bots suficientemente inteligentes para detectar campos ocultos (raro en spam automatizado, común en ataques dirigidos).
  • El rate limiting falla contra ataques distribuidos desde muchas IPs.

Juntas cubren el espectro completo de lo que un formulario de contacto en un sitio personal realmente enfrenta. Ninguna capa requiere una llamada de red a un servicio externo, ninguna agrega latencia a envíos legítimos, y ninguna revela al atacante qué mecanismo lo bloqueó.

Qué no cubre

Esto no detendrá a un humano haciendo spam manualmente desde una VPN. Para eso, necesitarías algo como Cloudflare Turnstile (la alternativa no invasiva a CAPTCHA que solo desafía tráfico sospechoso). Pero para el spam automatizado masivo que golpea todo formulario público, dos verificaciones silenciosas del lado del servidor son suficientes.

La implementación completa — incluyendo el handler del formulario, el CSS del honeypot y la validación — vive en src/worker.js si querés verlo en contexto.

Was this helpful?