Frena el spam en formularios de contacto7 min de lectura
Cómo añadir un campo honeypot que rellenan los bots y no las personas
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.
Añade un input de texto extra que ocultas fuera de pantalla, sacas del orden de tabulación y envuelves en aria-hidden, con un nombre que ningún navegador autocompleta. Las personas nunca lo rellenan; los bots que rellenan todos los inputs, sí. En el servidor, trata como spam cualquier envío que lo traiga con un valor y responde exactamente igual que a un mensaje real.
Qué tiene de especial un honeypot
Un honeypot tiene que ser dos cosas a la vez. Para un bot que lee el HTML, debe parecer un campo normal que vale la pena rellenar. Para cualquier persona, no debe existir, y eso incluye a quienes no usan el formulario como tú: alguien con lector de pantalla, alguien que navega con el teclado, alguien cuyo gestor de contraseñas rellena los formularios por él. La mayoría de los fallos de honeypot vienen de olvidar a alguna de esas personas, y el síntoma es un mensaje real archivado en silencio como spam.
- Una persona rellena el formulario con lo que use: una pantalla, un teclado o un lector de pantalla.
- La trampa está fuera de pantalla, fuera del orden de tabulación y oculta a la tecnología de asistencia, así que nunca llega a ella.
- Su nombre es uno que los navegadores y los gestores de contraseñas no autocompletan, así que se queda vacía.
- El envío llega con la trampa vacía y se recibe.
- Un bot de script lee el HTML y encuentra todos los inputs, incluida la trampa.
- Los rellena todos y envía al instante.
- La trampa tiene un valor, así que el envío es spam. El bot recibe igualmente una respuesta de éxito normal.
El markup
Pon la trampa dentro del formulario, junto a los campos reales, como un input de texto normal. Un input type="hidden" no sirve: los bots saben que las personas no pueden editarlos y los dejan en paz.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<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" 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>
<button>Send</button>
</form><style>
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>aria-hidden="true"en el contenedor quita la etiqueta y el input del árbol de accesibilidad, así que los lectores de pantalla no los anuncian.tabindex="-1"evita que la tecla Tab caiga en un input que nadie ve.- La etiqueta dice Leave this empty (déjalo vacío). Si alguna herramienta llega a mostrar el campo, quien lo lea sabe qué hacer.
- No añadas
required. Un campo obligatorio que las personas no pueden ver bloquea todos los envíos honestos.
Cómo ocultarlo
Los bots han aprendido a saltarse los campos que están ocultos de forma evidente. Los scrapers sencillos siguen rellenándolo todo, pero algunos miran el atributo hidden o un display: none en línea antes de escribir. Cuanto más lejos del propio input esté la ocultación, más bots caen.
| Técnica | Oculto para las personas | Bots que aún atrapa |
|---|---|---|
Atributo hidden o display: none en línea | Sí | Solo los más simples. Es lo primero que comprueba un bot más listo |
| Clase fuera de pantalla en tu hoja de estilos | Sí | La mayoría. El bot tendría que cargar y aplicar tu CSS para darse cuenta |
opacity: 0 o tamaño cero | Casi siempre. Algunas lupas y contornos de foco aún lo revelan | La mayoría, pero es más arriesgado para las personas |
| Añadido con JavaScript después de cargar | Sí | Bots que ejecutan scripts. Los scrapers de HTML no lo ven nunca, así que se le escapan |
Usa la clase fuera de pantalla de tu hoja de estilos. Atrapa más bots y es la más segura para las personas. Si un honeypot deja de funcionar, añade un segundo con otro nombre en lugar de un truco de ocultación más ingenioso.
Elige un nombre que el autocompletado ignore
Los navegadores y los gestores de contraseñas rellenan campos adivinando a partir de su name, su id, su etiqueta y su valor de autocomplete. autocomplete="off" es una sugerencia, y algunos navegadores la ignoran en campos que parecen direcciones o datos de contacto. Así que el nombre es lo que hace el trabajo de verdad.
| Evita | Por qué | Usa en su lugar |
|---|---|---|
email2, phone, name, address | El autocompletado los reconoce y los rellena para personas reales | _gotcha, company_website |
honeypot, trap, bot | Algunos bots se saltan los campos con nombres obvios | Un nombre soso y creíble |
url o website a secas | Algunas herramientas de autocompletado rellenan un campo de sitio web desde un perfil guardado | Un nombre compuesto, como company_website |
Compruébalo en el servidor
Un honeypot solo funciona si el servidor lo lee. Una comprobación en el navegador no sirve de nada, porque un bot nunca ejecuta tu script. Cuando el campo tenga un valor, responde exactamente igual que a un mensaje real, y no guardes ni reenvíes el envío. Una respuesta distinta le dice al bot qué campo lo delató. Los ejemplos de abajo llaman a tu propio saveMessage; cámbialo por lo que guarde o envíe por email el mensaje.
Un formulario HTML básico no tiene servidor propio. Apúntalo a Ikiform y llama a la trampa _gotcha, o renómbrala en cada formulario en Honeypot field. Ikiform hace la comprobación en el servidor, y la trampa nunca se guarda como respuesta.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<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" 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>
<button>Send</button>
</form>En una server action, redirige un envío atrapado a la misma página de agradecimiento que recibe uno real, y sáltate el guardado.
"use server";
import { redirect } from "next/navigation";
export async function sendMessage(formData: FormData) {
if (String(formData.get("company_website") ?? "").trim() !== "") {
console.info("honeypot", { at: new Date().toISOString() });
redirect("/contact/thanks");
}
await saveMessage({
email: String(formData.get("email") ?? ""),
message: String(formData.get("message") ?? ""),
});
redirect("/contact/thanks");
}En una Astro Action, saca la trampa del input y devuelve el resultado normal cuando tenga un valor.
import { defineAction } from "astro:actions";
import { z } from "astro/zod";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({
email: z.email(),
message: z.string().min(1),
company_website: z.string().nullish(),
}),
handler: async ({ company_website, ...fields }) => {
if (company_website?.trim()) return { sent: true };
await saveMessage(fields);
return { sent: true };
},
}),
};Lee la trampa en .validator() y sáltate el guardado en el handler. Ambos caminos devuelven la misma respuesta.
import { createServerFn } from "@tanstack/react-start";
export const sendMessage = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return {
email: String(data.get("email") ?? ""),
message: String(data.get("message") ?? ""),
trapped: String(data.get("company_website") ?? "").trim() !== "",
};
})
.handler(async ({ data: { trapped, ...fields } }) => {
if (!trapped) await saveMessage(fields);
return { ok: true };
});La action de la ruta redirige a la página de agradecimiento en ambos casos y solo guarda un envío sin la trampa rellena.
import { redirect } from "react-router";
import type { Route } from "./+types/contact";
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
if (String(form.get("company_website") ?? "").trim()) return redirect("/contact/thanks");
await saveMessage({ email: String(form.get("email")), message: String(form.get("message")) });
return redirect("/contact/thanks");
}La form action solo guarda cuando la trampa está vacía, y luego redirige con 303 en ambos casos.
import { redirect } from "@sveltejs/kit";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request }) => {
const form = await request.formData();
if (!String(form.get("company_website") ?? "").trim()) {
await saveMessage({ email: String(form.get("email")), message: String(form.get("message")) });
}
redirect(303, "/contact/thanks");
},
} satisfies Actions;La ruta de servidor responde 201 con el mismo cuerpo para un envío atrapado y para uno real.
export default defineEventHandler(async (event) => {
const body = await readBody<{ email?: string; message?: string; company_website?: string }>(event);
if (body.company_website?.trim()) {
setResponseStatus(event, 201);
return { sent: true };
}
await saveMessage({ email: body.email ?? "", message: body.message ?? "" });
setResponseStatus(event, 201);
return { sent: true };
});Una single-page app no puede comprobar la trampa por sí misma. Envíala con los demás campos y deja que decida el servidor al que envías. FormData la incluye sin código extra.
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});Igual en Vue: deja la trampa dentro del <form> para que new FormData(form) la incluya, y deja que el servidor la compruebe.
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});Cómo gestiona Ikiform el honeypot
Cada formulario de Ikiform tiene por defecto un honeypot llamado _gotcha. Cámbialo en cada formulario en Honeypot field, en los ajustes del formulario; el nombre que elijas queda reservado y nunca se guarda como respuesta. Un honeypot relleno suma 5 a la puntuación de spam, lo que alcanza por sí solo el umbral de 5.
Ikiform no descarta el envío. Lo guarda como spam, así que si alguna vez una persona real cae en la trampa, puedes encontrar el mensaje y restaurarlo. 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. La documentación de spam y Formularios HTML tienen la referencia.
Comprueba que funciona
- Carga el formulario con un lector de pantalla activado y recórrelo. La trampa nunca se anuncia.
- Recorre el formulario con Tab. El foco pasa del último campo real directamente al botón.
- Deja que tu navegador y tu gestor de contraseñas autocompleten el formulario. La trampa sigue vacía.
- 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. - Envía un mensaje normal. Llega como recibido, sin rastro del campo trampa.
Trampas habituales
- Envíos JSON. Si construyes el cuerpo a mano en lugar de con
FormData, incluye la trampa, o tu servidor la leerá vacía también para los bots. - Creadores de formularios que renombran campos. Algunos creadores añaden prefijos o renombran los inputs. Mira el cuerpo enviado, no el editor, para saber el nombre real de la trampa.
- Caché. Si una página en caché conserva el nombre antiguo de la trampa después de renombrarla, los envíos llegan con el nombre viejo y pasan. Renómbrala en ambos lados a la vez.
- Honeypots de checkbox. Una checkbox oculta también funciona, pero los lectores de pantalla gestionan peor las checkboxes sueltas que los inputs de texto. Mejor un input de texto.
- Una sola capa. Los bots que usan un navegador real y se saltan los campos invisibles pasan por delante de cualquier honeypot. Combínalo con un límite de frecuencia y, si hace falta, una verificación humana.
Preguntas
Sí, contra los más comunes: scripts que rellenan todos los inputs que encuentran. Los bots que usan un navegador completo y se saltan los campos invisibles pasan, así que usa el honeypot como primera capa, no como la única.
Solo si se oculta sin cuidado. Envuélvelo en un elemento con aria-hidden, dale al input tabindex -1, ponle la etiqueta Leave this empty y nunca lo marques como obligatorio. Así los lectores de pantalla y el teclado se lo saltan.
Normalmente por el autocompletado. Un navegador o un gestor de contraseñas rellenó la trampa porque su nombre parecía un campo real, como email2 o phone. Renómbrala con algo que ningún autocompletado reconozca.
No. Responde a un envío atrapado exactamente igual que a uno real. Un error le dice al bot qué campo lo atrapó, y la siguiente versión del bot se salta ese campo.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.