Frena el spam en formularios de contacto7 min de lectura
Cómo añadir Cloudflare Turnstile a un formulario HTML
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.
Añade el script de Turnstile y un elemento cf-turnstile dentro de tu formulario; el widget pone un token en un campo oculto cf-turnstile-response. Luego envía ese token y tu clave secreta al endpoint siteverify de Cloudflare desde un servidor, y acepta el envío solo si responde success. Sin servidor, deja que tu backend de formularios lo verifique, o usa la verificación gestionada de Ikiform, que no necesita ninguna clave.
Qué cambia con Turnstile
Turnstile tiene dos mitades, y un formulario HTML básico solo tiene sitio para una. El widget se ejecuta en el navegador con tu site key pública y genera un token. La comprobación se ejecuta en un servidor con tu secret key, que nunca debe llegar a la página. Un formulario que muestra el widget pero nunca verifica el token es decoración: un bot envía directamente a tu endpoint y se salta el widget por completo.
Así que la verdadera pregunta para un formulario HTML es dónde vive el secreto. Esta página cubre tres respuestas: tu propio servidor pequeño, un backend de formularios que guarda tus claves y una verificación gestionada en la que nadie salvo el backend guarda claves.
- El widget de Turnstile se ejecuta en el navegador con tu site key pública. La mayoría de los visitantes nunca lo ven hacer nada.
- Emite un token y lo pone en un input oculto cf-turnstile-response dentro del formulario.
- El formulario se envía, y el token viaja con los demás campos.
- Tu servidor envía la clave secreta, el token y la IP del visitante al endpoint siteverify de Cloudflare.
- Comprueba que la respuesta dice success y que el hostname y la action son los que esperas.
- Si todo coincide, el envío se acepta. Si algo no cuadra, se rechaza.
Añade el widget
Crea un widget en el panel de Cloudflare, en Turnstile, indica los hostnames donde funciona tu formulario y copia la site key. Luego añade el script y un elemento cf-turnstile dentro del formulario. El elemento debe estar dentro del <form>, o el campo oculto del token queda fuera y nunca se envía.
<form action="https://example.com/api/contact" method="POST">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<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>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
<button>Send message</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>data-action="contact"etiqueta el widget. Siteverify lo devuelve, así que tu servidor puede rechazar un token emitido para otro formulario.- El script debe cargarse desde la URL propia de Cloudflare. Cloudflare indica que una copia a través de proxy o alojada por ti rompe el widget.
- Si tu sitio envía una Content Security Policy, permite
https://challenges.cloudflare.comtanto enscript-srccomo enframe-src. - Sin coste, Cloudflare permite hasta 20 widgets, cada uno en hasta 10 hostnames, así que incluye también los hosts de staging.
Verifica el token en tu propio servidor
Cualquier servidor puede verificar un token: un POST a siteverify con el secreto y el token. Aquí lo tienes como un Cloudflare Worker al que envía un formulario estático. Comprueba el token, el hostname y la action, descarta el token y reenvía los campos a Ikiform con una clave secreta. Cambia el reenvío por cualquier otra cosa que guarde o envíe por email el mensaje.
interface Env {
TURNSTILE_SECRET_KEY: string;
IKIFORM_SECRET_KEY: string;
}
interface Verdict {
success: boolean;
hostname?: string;
action?: string;
"error-codes"?: string[];
}
const SITE = "https://example.com";
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
const form = await request.formData();
const check = await fetch(SITEVERIFY, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: env.TURNSTILE_SECRET_KEY,
response: String(form.get("cf-turnstile-response") ?? ""),
remoteip: request.headers.get("CF-Connecting-IP"),
idempotency_key: crypto.randomUUID(),
}),
});
const verdict = (await check.json()) as Verdict;
const expected = verdict.hostname === new URL(SITE).hostname && verdict.action === "contact";
if (!verdict.success || !expected) {
console.warn("turnstile", verdict["error-codes"] ?? [], verdict.hostname, verdict.action);
return Response.redirect(`${SITE}/contact?error=check`, 303);
}
form.delete("cf-turnstile-response");
const sent = await fetch(ENDPOINT, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${env.IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("Origin") ?? SITE,
},
body: form,
});
return Response.redirect(sent.ok ? `${SITE}/thanks` : `${SITE}/contact?error=send`, 303);
},
};- Siteverify acepta cuerpos JSON o form-encoded y siempre responde en JSON.
remoteipes opcional;idempotency_keyte permite reintentar una verificación con seguridad tras un error de red. - Comprueba
hostnameyaction, no solosuccess. Un token emitido en la página de otra persona con tu site key también dicesuccess. - Guarda ambos secretos con
wrangler secret put, nunca enwrangler.toml. - El Worker responde con una redirección
303, así que el formulario básico no necesita JavaScript. Apunta elactiondel formulario a la ruta del Worker. - Reenvía la cabecera
Originde la página, porque un fetch del lado del servidor no envía ninguna e Ikiform la lee para reconocer las pruebas gratuitas desdelocalhost.
Para mostrar el error en la página del formulario tras una verificación fallida, lee la query string que pone el Worker:
<p id="form-error" role="alert" hidden></p>
<script>
const reason = new URLSearchParams(location.search).get("error");
const banner = document.getElementById("form-error");
if (reason && banner) {
banner.textContent =
reason === "check" ? "The human check didn't pass. Try again." : "Your message didn't send. Try again.";
banner.hidden = false;
}
</script>IKIFORM_SECRET_KEY en variables de entorno solo de servidor.Deja que Ikiform lo verifique
Si no quieres mantener ningún servidor, el formulario puede enviar directamente a Ikiform y que Ikiform haga la llamada a siteverify. El ajuste Spam check de cada formulario decide cómo.
| Vía | Claves que gestionas | Widget en tu página | Servidor propio |
|---|---|---|---|
| Tu propio servidor o Worker, arriba | Site key y secret key | Sí, el tuyo | Sí |
| Ikiform con Your own Turnstile keys | Site key y secret key, pegadas en Ikiform | Sí, el tuyo | No |
| Ikiform con Ikiform | Ninguno | No. Una página de verificación en ikiform.com, o un pequeño frame | No |
Con Your own Turnstile keys, pega la site key y la secret key en los ajustes del formulario. El secreto se guarda cifrado. Mantén el mismo markup del widget, pero apunta el formulario a tu endpoint de Ikiform. Ikiform verifica cf-turnstile-response y compara el hostname del token con los Allowed domains del formulario. Un envío sin un token válido recibe 403 captcha_failed, incluso con una clave de API secreta.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<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>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
<button>Send message</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>Con Ikiform, no añades nada a la página. Un envío HTML básico sin token recibe una breve página Checking you're human en ikiform.com que ejecuta Turnstile y reenvía los campos. Un envío con fetch recibe en su lugar 403 challenge_required; llama antes a challenge() de @ikiform/sdk para obtener un token. No interviene ninguna cuenta de Cloudflare. La documentación de spam muestra ambos flujos.
Si no se puede contactar con Cloudflare, Ikiform no descarta el envío: lo guarda como spam con el motivo captcha_unavailable, para que puedas restaurarlo desde el panel. Tu propio servidor debería tomar esa misma decisión a conciencia, ya sea fallando de forma segura como el Worker de arriba o conservando el envío para revisarlo.
Claves de prueba
Cloudflare publica claves de prueba que funcionan en cualquier hostname, incluido localhost. Úsalas mientras desarrollas y luego cámbialas por tus claves reales.
| Clave | Valor | Comportamiento |
|---|---|---|
| Site key | 1x00000000000000000000AA | Siempre pasa, widget visible |
| Secret key | 1x0000000000000000000000000000000AA | Siteverify siempre responde success |
| Secret key | 2x0000000000000000000000000000000AA | Siteverify siempre responde con un fallo |
Los tokens de prueba no llevan tu hostname real, así que la comprobación de hostname del Worker los rechaza. Relaja esa comprobación solo en tu entorno de desarrollo, nunca en producción.
Comprueba que funciona
- Envía el formulario con normalidad. El envío se acepta y el widget no mostró nada, o solo una casilla.
- Quita el elemento
cf-turnstiley envía. El envío se rechaza: el Worker redirige con?error=check, o Ikiform responde403 captcha_failed. - Envía, pulsa Atrás y vuelve a enviar la misma página. El token reutilizado falla, lo que demuestra que el servidor de verdad lo verifica.
- Abre Submissions en el panel. El nuevo envío está ahí con todos los campos que mandaste.
- ¿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
- Un token, un intento. Tras un envío fallido gestionado con
fetch, llama aturnstile.reset()para obtener un token nuevo. Una recarga completa de la página obtiene uno automáticamente. - Quien escribe despacio. Los tokens caducan a los 300 segundos. El ajuste
data-refresh-expireddel widget decide si obtiene uno nuevo por sí solo o espera un clic. - www y el dominio raíz. Para siteverify,
www.example.comyexample.comson hostnames distintos. Compara con el que usa realmente tu formulario, o permite ambos. - Navegación de single-page app. El script implícito renderiza los widgets una sola vez, al cargar. En un cambio de ruta en el cliente, renderiza de forma explícita con
?render=explicityturnstile.render(). - Envíos de servidor con las claves de Ikiform. Si tu servidor reenvía a Ikiform con una clave secreta, Ikiform se salta su verificación gestionada. Verifica antes el token tú mismo, como hace el Worker.
Preguntas
El widget funciona en cualquier sitio, pero el token debe verificarse con tu clave secreta en un servidor. Si no tienes uno, envía el formulario a un backend de formularios que lo verifique por ti, o usa la verificación gestionada de Ikiform, que no necesita claves.
Cloudflare lo ofrece sin coste para hasta 20 widgets con hasta 10 hostnames cada uno, y su página de planes indica desafíos ilimitados. Consulta la página de planes actual de Cloudflare antes de contar con ello.
El token tiene más de 300 segundos o ya se verificó una vez. Renderiza un widget nuevo, o reinícialo, después de cada intento de envío.
No. Pon el Spam check del formulario en Ikiform e Ikiform ejecutará Turnstile en ikiform.com. Solo necesitas una cuenta para usar tus propias claves y tu propio widget.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.