Frena el spam en formularios de contacto

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.

Preet SutharFundador, Ikiform

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.

La vida de un token de Turnstile, del widget al veredictoEn el navegador, con la site keyEn un servidor, con la secret keyel widget seejecutanormalmenteinvisibletoken emitidoinput ocultocf-turnstile-responseel formulario seenvíael token viaja conlos campossiteverifysecreto + token + IPdel visitantecomprobar larespuestasuccess, hostname,actionaceptar el envíorechazarenvíotodo coincidealgo no cuadraLa vida de un token de Turnstile, del widget al veredictoEn el navegador, con la site keyEn un servidor, con la secret keyel widget seejecutanormalmenteinvisibletoken emitidoinput ocultocf-turnstile-responseel formulario seenvíael token viaja conlos campossiteverifysecreto + token +IP del visitantecomprobar larespuestasuccess, hostname,actionaceptar el envíorechazarenvíotodo coincidealgo no cuadra
  1. 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.
  2. Emite un token y lo pone en un input oculto cf-turnstile-response dentro del formulario.
  3. El formulario se envía, y el token viaja con los demás campos.
  4. Tu servidor envía la clave secreta, el token y la IP del visitante al endpoint siteverify de Cloudflare.
  5. Comprueba que la respuesta dice success y que el hostname y la action son los que esperas.
  6. Si todo coincide, el envío se acepta. Si algo no cuadra, se rechaza.
Un token es válido durante 300 segundos y se puede verificar una vez. Una segunda verificación del mismo token falla con timeout-or-duplicate.

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.

contact.html
<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.com tanto en script-src como en frame-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.

src/index.ts
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. remoteip es opcional; idempotency_key te permite reintentar una verificación con seguridad tras un error de red.
  • Comprueba hostname y action, no solo success. Un token emitido en la página de otra persona con tu site key también dice success.
  • Guarda ambos secretos con wrangler secret put, nunca en wrangler.toml.
  • El Worker responde con una redirección 303, así que el formulario básico no necesita JavaScript. Apunta el action del formulario a la ruta del Worker.
  • Reenvía la cabecera Origin de la página, porque un fetch del lado del servidor no envía ninguna e Ikiform la lee para reconocer las pruebas gratuitas desde localhost.

Para mostrar el error en la página del formulario tras una verificación fallida, lee la query string que pone el Worker:

contact.html
<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>
Las versiones de servidor envían con una clave secreta, así que Ikiform se salta Allowed domains y su verificación humana. Todos los envíos llegan entonces desde la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el límite de frecuencia Relaxed o Custom para un formulario que envías a través de un servidor, y guarda 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íaClaves que gestionasWidget en tu páginaServidor propio
Tu propio servidor o Worker, arribaSite key y secret keySí, el tuyoSí
Ikiform con Your own Turnstile keysSite key y secret key, pegadas en IkiformSí, el tuyoNo
Ikiform con IkiformNingunoNo. Una página de verificación en ikiform.com, o un pequeño frameNo

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.

contact.html
<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.

ClaveValorComportamiento
Site key1x00000000000000000000AASiempre pasa, widget visible
Secret key1x0000000000000000000000000000000AASiteverify siempre responde success
Secret key2x0000000000000000000000000000000AASiteverify 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

  1. Envía el formulario con normalidad. El envío se acepta y el widget no mostró nada, o solo una casilla.
  2. Quita el elemento cf-turnstile y envía. El envío se rechaza: el Worker redirige con ?error=check, o Ikiform responde 403 captcha_failed.
  3. 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.
  4. Abre Submissions en el panel. El nuevo envío está ahí con todos los campos que mandaste.
  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

  • Un token, un intento. Tras un envío fallido gestionado con fetch, llama a turnstile.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-expired del widget decide si obtiene uno nuevo por sí solo o espera un clic.
  • www y el dominio raíz. Para siteverify, www.example.com y example.com son 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=explicit y turnstile.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

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.