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

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

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.

Diagrama: Tres formas de conectar el formulario de un sitio estático, y dónde se ejecutan las comprobaciones antispam en cada una

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ó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](https://ikiform.com/alternatives/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`.

**Hugo** (`layouts/partials/contact-form.html`)

```html
<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>
```

**Jekyll** (`_includes/contact-form.html`)

```html
<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/' | absolute_url }}" />
  <p role="status"></p>
  <button>Send message</button>
</form>
```

**Eleventy** (`_includes/contact-form.njk`)

```html
<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="{{ site.url }}/thanks/" />
  <p role="status"></p>
  <button>Send message</button>
</form>
```

`assets/contact.css`:

```html
<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](https://ikiform.com/es/guides/spam-protection/honeypot.md) 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](https://ikiform.com/es/docs/guides/hugo.md), [Jekyll](https://ikiform.com/es/docs/guides/jekyll.md) y [Eleventy](https://ikiform.com/es/docs/guides/eleventy.md) 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`:

```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`:

```ts
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](https://ikiform.com/es/guides/spam-protection/rate-limiting.md) 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

### ¿Puedo añadir un captcha a un sitio estático?

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.

### ¿Basta un honeypot para un sitio estático?

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.

### ¿Por qué mi formulario estático muestra 405 Method Not Allowed?

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.

### ¿El formulario funciona con JavaScript desactivado?

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.

## 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.
- [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.
- [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.
- [Sin captcha](https://ikiform.com/es/guides/spam-protection/without-captcha.md): 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.
- [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.
