# Cómo frenar el spam en formularios sin captcha

> Frena 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.

Publicado el 2026-10-25 · https://ikiform.com/es/guides/spam-protection/without-captcha

Combina cuatro comprobaciones invisibles y suma lo que encuentren: un honeypot, una marca de tiempo firmada que muestra cuánto tiempo estuvo abierto el formulario, una puntuación para enlaces y frases de spam, y un límite de frecuencia por visitante. Trata un envío como spam solo cuando el total supere un umbral, para que ninguna señal leve por sí sola bloquee a una persona real. Guarda lo que atrapes, y añade un captcha más adelante solo si la carpeta de spam muestra que siguen colándose bots.

## Qué cambia sin captcha

Un captcha te da un sí o un no rotundo. Sin él, tienes en cambio varias señales débiles, y cada una por separado es fácil de esquivar o fácil de activar por accidente. Un envío rápido puede ser un bot, o alguien que pegó un mensaje preparado. Un enlace puede ser spam, o un portfolio. El truco es puntuar: dar un peso a cada señal, sumarlas y solo llamarlo spam pasado un umbral. La única señal fuerte, un honeypot relleno, tiene un peso suficiente para cruzar la línea por sí sola.

Diagrama: Cómo las señales invisibles se suman hasta un veredicto en tres envíos distintos

1. Un bot de script rellena el honeypot, que suma 5, y envía en menos de un segundo, que suma 3. Su total de 8 es spam.
2. Una oferta de SEO pegada tiene un enlace en el campo del nombre, 3, y una frase de spam conocida, 2. Su total de 5 alcanza el umbral de 5.
3. Un mensaje real con tres enlaces puntúa 2 y nada más, así que se recibe.

Los pesos son los de Ikiform, con un umbral de 5. Dos señales leves juntas lo cruzan; una sola, nunca.

## Una hora de renderizado firmada

Las personas necesitan varios segundos para rellenar un formulario. Una trampa de tiempo mide ese intervalo. La versión débil pone `Date.now()` en un campo oculto, que cualquier bot puede reescribir. La versión fuerte firma la hora de renderizado en el servidor con un secreto, así que un bot no puede afirmar que la página se abrió antes de lo que se abrió. Este archivo funciona en cualquier entorno con Web Crypto: Node 20 o posterior, Deno, Bun, Workers y cualquier runtime serverless.

`lib/render-token.ts`:

```ts
const encoder = new TextEncoder();

async function key(secret: string) {
  return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
    "sign",
    "verify",
  ]);
}

function toHex(bytes: ArrayBuffer) {
  return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}

function fromHex(hex: string) {
  return new Uint8Array((hex.match(/../g) ?? []).map((pair) => Number.parseInt(pair, 16)));
}

export async function signRender(secret: string, now = Date.now()) {
  const signature = await crypto.subtle.sign("HMAC", await key(secret), encoder.encode(`render.${now}`));
  return `${now}.${toHex(signature)}`;
}

export type RenderCheck = "ok" | "too_fast" | "invalid";

export async function checkRender(secret: string, token: string, minSeconds = 3, now = Date.now()): Promise<RenderCheck> {
  const [time = "", signature = ""] = token.split(".");
  if (!/^\d{13}$/.test(time) || !/^[0-9a-f]{64}$/.test(signature)) return "invalid";
  const valid = await crypto.subtle.verify("HMAC", await key(secret), fromHex(signature), encoder.encode(`render.${time}`));
  const age = now - Number(time);
  if (!valid || age < 0 || age > 86_400_000) return "invalid";
  return age < minSeconds * 1000 ? "too_fast" : "ok";
}
```

Firma cuando se renderiza la página, así que la página debe renderizarse en cada petición, no cachearse como HTML estático. En Astro:

`src/pages/contact.astro`:

```astro
---
import { signRender } from "../lib/render-token";

export const prerender = false;

const rendered = await signRender(import.meta.env.RENDER_SECRET);
---

<form method="POST" action="/api/contact">
  <input type="hidden" name="rendered" value={rendered} />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <label for="name">Name</label>
  <input id="name" name="name" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>
  <button>Send</button>
</form>
```

- Tres segundos es un mínimo seguro para un formulario de contacto. Alguien que pega un mensaje preparado puede tardar aun así dos.
- Rechaza los tokens de más de un día. Si no, un bot carga la página una vez y reutiliza el mismo token durante semanas.
- Trata un token ausente o falsificado como una señal, no como una prueba. Una página en caché o una pestaña antigua pueden producir uno.

## Puntúa el envío

Reúne las señales en una función. Las comprobaciones de enlaces y frases atrapan lo que el honeypot y el tiempo no pueden: mensajes escritos o pegados por personas. Mantén la lista de frases corta y específica; palabras amplias como `free` marcan a clientes reales.

`lib/score.ts`:

```ts
const WEIGHTS = { honeypot: 5, too_fast: 3, invalid_render: 3, link_in_name: 3, many_links: 2, phrase: 2 };
const THRESHOLD = 5;
const LINK = /https?:\/\/|www\./gi;
const PHRASES = /\b(seo services|guest post|backlinks|first page of google|crypto investment)\b/i;

type Reason = keyof typeof WEIGHTS;

export function scoreContact(input: {
  name: string;
  message: string;
  trap: string;
  render: "ok" | "too_fast" | "invalid";
}) {
  const reasons: Reason[] = [];
  if (input.trap.trim()) reasons.push("honeypot");
  if (input.render === "too_fast") reasons.push("too_fast");
  if (input.render === "invalid") reasons.push("invalid_render");
  if (input.name.match(LINK)) reasons.push("link_in_name");
  if ((`${input.name} ${input.message}`.match(LINK)?.length ?? 0) >= 3) reasons.push("many_links");
  if (PHRASES.test(`${input.name} ${input.message}`)) reasons.push("phrase");
  const score = reasons.reduce((total, reason) => total + WEIGHTS[reason], 0);
  return { score, reasons, spam: score >= THRESHOLD };
}
```

`src/pages/api/contact.ts`:

```ts
import type { APIRoute } from "astro";
import { checkRender } from "../../lib/render-token";
import { scoreContact } from "../../lib/score";

export const POST: APIRoute = async ({ request, redirect }) => {
  const form = await request.formData();
  const render = await checkRender(import.meta.env.RENDER_SECRET, String(form.get("rendered") ?? ""));
  const verdict = scoreContact({
    name: String(form.get("name") ?? ""),
    message: String(form.get("message") ?? ""),
    trap: String(form.get("_gotcha") ?? ""),
    render,
  });
  await saveMessage(Object.fromEntries(form), verdict.spam ? "spam" : "received", verdict.reasons);
  return redirect("/thanks", 303);
};
```

El handler guarda todo con un estado en lugar de descartar el spam, y responde a ambos con la misma redirección. Revisas el spam después, y un bot nunca sabe qué señal lo atrapó. Añade un [límite de frecuencia por visitante](https://ikiform.com/es/guides/spam-protection/rate-limiting.md) delante de esta ruta para frenar los floods antes de que se ejecute nada de esto.

## Ikiform los ejecuta sin captcha

Cada formulario de Ikiform ejecuta el honeypot, el tiempo, la puntuación del contenido y los límites de frecuencia sin configuración, y su **Spam check** empieza en **Off**. El honeypot es `_gotcha`. La puntuación del contenido revisa enlaces, 22 frases de spam conocidas, dominios de email desechables y repeticiones idénticas en 24 horas. El límite de frecuencia empieza en 5 por minuto por visitante. Con 5 o más, un envío es spam.

| Señal | Peso |
| --- | --- |
| Honeypot relleno | +5 |
| Enviado menos de 2 segundos después del renderizado firmado | +3 |
| Hora de renderizado falsificada o de hace más de 24 horas | +3 |
| Un enlace en un campo de nombre o asunto | +3 |
| Campos idénticos en 24 horas | +3 |
| Tres o más enlaces | +2 |
| Una frase de spam conocida | +2 |
| Un dominio de email desechable | +2 |

La hora de renderizado firmada de Ikiform viene del esquema público del formulario. El [componente de shadcn/ui](https://ikiform.com/es/docs/shadcn.md) la envía por ti. Un formulario que renderizas tú puede obtenerla con `getSchema()` de `@ikiform/sdk` y devolverla como `_ikiform_ts`:

`contact.js`:

```js
import { getSchema, submit } from "@ikiform/sdk";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const form = document.querySelector("form");
const schema = await getSchema(FORM_ID);
const rendered = schema.ok ? schema.schema.ts : null;

form?.addEventListener("submit", async (event) => {
  event.preventDefault();
  const data = new FormData(form);
  if (rendered) data.set("_ikiform_ts", rendered);
  const result = await submit(FORM_ID, data);
  form.replaceWith(result.ok ? "Thanks, we'll be in touch." : result.error.message);
});
```

Los formularios HTML básicos no envían hora de renderizado y no se les penaliza por ello, así que que falte no cuesta nada. 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. El [endpoint del esquema](https://ikiform.com/es/docs/shadcn.md#schema-endpoint) y la [documentación de spam](https://ikiform.com/es/docs/spam.md#scoring) tienen los detalles.

> Si aún se cuelan algunos bots, pon **Spam check** en **Ikiform**. Añade Cloudflare Turnstile sin claves, y la mayoría de los visitantes no ven nada. Ese es el momento de añadir un captcha: cuando lo diga la carpeta de spam, no antes.

## Comprueba que funciona

1. Carga el formulario y envíalo en menos de un segundo. Con tu propio handler, el envío se guarda como spam con `too_fast`.
2. Edita el valor oculto `rendered` en las herramientas de desarrollo del navegador y envía. Puntúa como `invalid_render`.
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. Envía un mensaje con tres enlaces y nada más sospechoso. Se recibe.
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

- **Páginas estáticas.** Una página generada una vez al desplegar tiene una sola hora de renderizado para todos los visitantes. Fírmala en cada petición, u obtén un token nuevo con JavaScript cuando aparezca el formulario.
- **Gestores de contraseñas y pegar.** Algunas personas rellenan un formulario en un segundo con el autocompletado. Mantén el tiempo como una señal leve para que nunca bloquee por sí solo.
- **Las listas de frases caducan.** Las frases de spam cambian. Revisa lo que se cuela cada pocas semanas y añade la frase concreta, nunca una palabra común.
- **Las repeticiones son normales.** La gente reenvía cuando no recibe respuesta. Dale peso a una repetición idéntica, pero no la bloquees sin más.

## Preguntas

### ¿Puedo frenar el spam en formularios sin captcha?

Casi todo. Un honeypot, una hora de renderizado firmada, una puntuación del contenido y un límite de frecuencia frenan la mayor parte de lo que recibe un sitio pequeño, sin coste para los visitantes reales. Los bots que usan un navegador completo y esperan pueden seguir colándose, y ahí es donde ayuda una verificación invisible como Turnstile.

### ¿Cuánto debe esperar una trampa de tiempo?

Unos tres segundos para un formulario de contacto. Menos deja pasar bots rápidos; más empieza a atrapar a personas que pegan un mensaje preparado o usan el autocompletado. Úsala como una señal entre varias, no como un bloqueo estricto.

### ¿Por qué firmar la marca de tiempo?

Cualquier bot puede reescribir una marca de tiempo sin firmar en un campo oculto para que parezca más antigua. Firmarla en el servidor con un secreto significa que solo tu servidor puede producir una hora de renderizado válida.

### ¿Ikiform necesita un captcha para filtrar el spam?

No. El honeypot, el tiempo firmado, la puntuación del contenido y los límites de frecuencia se ejecutan en cada formulario con la verificación antispam desactivada. La verificación Turnstile gestionada es un extra que activas por formulario si el spam sigue colándose.

## Más guías

- [Frena el spam en formularios de contacto](https://ikiform.com/es/guides/spam-protection.md): 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.
- [Campo honeypot](https://ikiform.com/es/guides/spam-protection/honeypot.md): Añ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.
- [Límites de frecuencia](https://ikiform.com/es/guides/spam-protection/rate-limiting.md): Limita 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.
- [Alternativas a reCAPTCHA](https://ikiform.com/es/guides/spam-protection/recaptcha-alternatives.md): Compara 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.
- [Cloudflare Turnstile](https://ikiform.com/es/guides/spam-protection/cloudflare-turnstile.md): Añ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.
- [Sitios estáticos](https://ikiform.com/es/guides/spam-protection/static-site.md): 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.
