Cloudflare Turnstile a fondo: protege tus formularios de bots sin CAPTCHA

Qué es Turnstile y cómo funciona: widget en el navegador, token de un solo uso y validación obligatoria con Siteverify en el servidor. Modos, límites del plan gratuito, claves de prueba y un Worker que valida tokens probado contra el Siteverify real.

Portada: Cloudflare Turnstile a fondo

Cualquier formulario público acaba recibiendo spam: registros falsos, comentarios con enlaces, intentos de fuerza bruta en el login. El remedio clásico era un CAPTCHA de semáforos y pasos de cebra, que molesta a las personas y cada vez frena menos a los bots.

Turnstile es la alternativa de Cloudflare: un widget que ejecuta en el navegador una serie de comprobaciones invisibles y, si todo cuadra, entrega un token. Tu servidor valida ese token con Cloudflare antes de aceptar el formulario. La mayoría de visitantes no tienen que hacer nada; como mucho, marcar una casilla. Y no hace falta que tu web pase por Cloudflare: funciona en cualquier sitio.

En este artículo vemos cómo funciona el flujo completo, los modos del widget, qué ofrece el plan gratuito y un Worker que valida tokens, probado contra el endpoint real de Cloudflare con las claves de prueba oficiales.

🧪
La validación en el servidor se ha probado de verdad el 3 de octubre de 2026 con Wrangler 4.147.0 y Node.js 24.21.0: el Worker corría en local y llamaba al Siteverify real de Cloudflare con las claves de prueba públicas, sin cuenta. El widget en un navegador no lo he probado: el HTML que ves es el de la documentación. Planes y límites comprobados en la documentación oficial ese mismo día.

Cómo funciona

El flujo tiene cuatro pasos y el último es el que más gente se salta:

  1. Tu página carga el script de Turnstile y muestra el widget con tu clave de sitio (sitekey, pública).
  2. El widget hace sus comprobaciones en el navegador y obtiene un token. En un formulario normal lo añade como campo oculto cf-turnstile-response.
  3. El navegador envía el formulario con el token a tu servidor.
  4. Tu servidor llama a la API Siteverify con el token y tu clave secreta. Solo si responde success: true aceptas la petición.
Esquema de Turnstile: el navegador carga el widget con la clave de sitio y obtiene un token; envía el formulario con el token a tu servidor; el servidor valida el token en Siteverify con la clave secreta y solo entonces procesa el formulario
El widget solo emite el token. Quien protege el formulario es la validación en tu servidor. Diagrama: devutilites.
🚨
Sin el paso 4 no hay protección. La documentación lo dice sin rodeos: el widget por sí solo no protege tus formularios. Un bot puede enviar el formulario directamente sin cargar la página. Valida siempre en el servidor.

Dos propiedades del token marcan el diseño:

  • Caduca a los 300 segundos (5 minutos). Si el usuario tarda más en enviar, Siteverify devuelve timeout-or-duplicate. El widget lo renueva solo por defecto (refresh-expired: auto).
  • Es de un solo uso. Un token ya validado se rechaza. Si tu servidor necesita reintentar la llamada a Siteverify por un fallo de red, manda un idempotency_key (un UUID tuyo) para que el reintento no cuente como repetición.

Modos del widget

ModoQué ve el visitante
Managed (recomendado)Cloudflare decide según el riesgo: casi siempre nada, a veces una casilla
Non-InteractiveUn widget con un indicador de carga; nunca tiene que interactuar
InvisibleNada en absoluto; todo ocurre en segundo plano

El modo se elige al crear el widget en el panel. En el HTML puedes ajustar el tema (light, dark, auto), el tamaño (normal, flexible, compact), el idioma (data-language="es"), cuándo se muestra (data-appearance) y dos valores que te devuelve Siteverify: data-action (hasta 32 caracteres) para distinguir formularios y data-cdata (hasta 255) para datos propios.

Planes y límites

ConceptoFreeEnterprise
PrecioGratisContactar con ventas
WidgetsHasta 20Ilimitados
Desafíos y verificacionesIlimitadosIlimitados
Hostnames por widget10Hasta 200
Todos los modos de widgetSíSí

El token puede tener hasta 2048 caracteres. La documentación recomienda no permitir localhost en las claves de producción; para desarrollo están las claves de prueba.

Claves de prueba

Cloudflare publica claves de sitio y secretas que se comportan siempre igual. Funcionan en cualquier dominio, también en localhost, y son perfectas para tests automáticos (Playwright o Cypress se detectan como bots con las claves reales).

Clave secretaComportamiento
1x0000000000000000000000000000000AASiempre valida
2x0000000000000000000000000000000AASiempre falla
3x0000000000000000000000000000000AADevuelve «token ya usado»
Clave de sitioComportamiento
1x00000000000000000000AASiempre pasa (visible)
2x00000000000000000000ABSiempre falla (visible)
1x00000000000000000000BBSiempre pasa (invisible)
3x00000000000000000000FFFuerza un desafío interactivo

Las claves de sitio de prueba generan el token ficticio XXXX.DUMMY.TOKEN.XXXX. Las claves secretas de producción lo rechazan, así que en pruebas usa también una clave secreta de prueba.

El código

La configuración del Worker. La clave de sitio es pública y va en vars; la secreta, en .dev.vars en local y con wrangler secret put TURNSTILE_SECRET en producción:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "turnstile-lab",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "vars": {
    // Clave de sitio (pública). Esta es la de pruebas que siempre pasa.
    "TURNSTILE_SITEKEY": "1x00000000000000000000AA",
    // Con las claves de prueba, siteverify devuelve hostname "example.com"
    "HOSTNAME_ESPERADO": "example.com"
  }
  // La clave secreta va en .dev.vars en local y con `wrangler secret put` en producción
}

El Worker sirve el formulario y lo valida. Antes de llamar a Siteverify descarta los casos obvios (sin token o demasiado largo), pone un tiempo máximo a la llamada y, si todo va bien, comprueba además el hostname:

const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";

type Resultado = {
  success: boolean;
  "error-codes": string[];
  hostname?: string;
  action?: string;
  challenge_ts?: string;
};

async function validar(token: string, ip: string | null, secret: string): Promise<Resultado> {
  // Comprobaciones baratas antes de llamar a Cloudflare
  if (!token) return { success: false, "error-codes": ["missing-input-response"] };
  if (token.length > 2048) return { success: false, "error-codes": ["invalid-input-response"] };

  const datos = new FormData();
  datos.append("secret", secret);
  datos.append("response", token);
  if (ip) datos.append("remoteip", ip);
  // Permite reintentar la validación sin que cuente como token repetido
  datos.append("idempotency_key", crypto.randomUUID());

  try {
    const r = await fetch(SITEVERIFY, { method: "POST", body: datos, signal: AbortSignal.timeout(10_000) });
    return await r.json<Resultado>();
  } catch {
    return { success: false, "error-codes": ["internal-error"] };
  }
}

const pagina = (sitekey: string) => `<!doctype html>
<html lang="es"><head><meta charset="utf-8"><title>Contacto</title>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head><body>
<form method="POST" action="/contacto">
  <input name="email" type="email" required placeholder="[email protected]">
  <textarea name="mensaje" required></textarea>
  <!-- El widget añade al formulario un campo oculto cf-turnstile-response -->
  <div class="cf-turnstile" data-sitekey="${sitekey}" data-action="contacto" data-language="es"></div>
  <button>Enviar</button>
</form></body></html>`;

export default {
  async fetch(request, env): Promise<Response> {
    const url = new URL(request.url);

    if (request.method === "GET" && url.pathname === "/") {
      return new Response(pagina(env.TURNSTILE_SITEKEY), {
        headers: { "content-type": "text/html; charset=utf-8" },
      });
    }

    if (request.method === "POST" && url.pathname === "/contacto") {
      const form = await request.formData();
      const token = String(form.get("cf-turnstile-response") ?? "");
      const ip = request.headers.get("CF-Connecting-IP");

      const r = await validar(token, ip, env.TURNSTILE_SECRET);
      if (!r.success) {
        return Response.json({ ok: false, errores: r["error-codes"] }, { status: 403 });
      }
      // Defensa extra: el token se generó en TU dominio
      if (r.hostname !== env.HOSTNAME_ESPERADO) {
        return Response.json({ ok: false, errores: ["hostname-inesperado"] }, { status: 403 });
      }
      // Aquí ya puedes guardar el mensaje, enviarlo por correo, etc.
      return Response.json({ ok: true, resuelto: r.challenge_ts });
    }

    return new Response("No encontrado", { status: 404 });
  },
} satisfies ExportedHandler<Env>;

Prueba contra el Siteverify real

Con el Worker en local y cambiando la clave secreta en .dev.vars entre ejecuciones:

$ curl -s localhost:8805/ | grep cf-turnstile
  <div class="cf-turnstile" data-sitekey="1x00000000000000000000AA" data-action="contacto" data-language="es"></div>
# TURNSTILE_SECRET=1x…AA (siempre pasa)
$ curl -s -X POST localhost:8805/contacto -d [email protected] -d mensaje=hola -d cf-turnstile-response=XXXX.DUMMY.TOKEN.XXXX
{"ok":true,"resuelto":"2026-10-02T22:39:52.944Z"} (HTTP 200)
$ curl -s -X POST localhost:8805/contacto -d [email protected] -d mensaje=hola
{"ok":false,"errores":["missing-input-response"]} (HTTP 403)
# TURNSTILE_SECRET=2x…AA (siempre falla)
$ curl -s -X POST localhost:8805/contacto -d [email protected] -d mensaje=hola -d cf-turnstile-response=XXXX.DUMMY.TOKEN.XXXX
{"ok":false,"errores":["invalid-input-response"]} (HTTP 403)
# TURNSTILE_SECRET=3x…AA (token ya usado)
$ curl -s -X POST localhost:8805/contacto -d [email protected] -d mensaje=hola -d cf-turnstile-response=XXXX.DUMMY.TOKEN.XXXX
{"ok":false,"errores":["timeout-or-duplicate"]} (HTTP 403)

Las tres claves secretas hacen lo que promete la documentación. Llamando a Siteverify directamente con curl se ve la respuesta completa de la clave que siempre valida:

curl -s https://challenges.cloudflare.com/turnstile/v0/siteverify \
  -d secret=1x0000000000000000000000000000000AA -d response=XXXX.DUMMY.TOKEN.XXXX
{"challenge_ts":"2026-10-02T22:39:17.185Z","error-codes":[],"hostname":"example.com","metadata":{"result_with_testing_key":true},"success":true}

Tres detalles que no esperaba. Con las claves de prueba, hostname es siempre example.com y no hay action; por eso el Worker lee el hostname esperado de una variable y no lo escribe a fuego. La respuesta trae metadata.result_with_testing_key: true, útil para detectar que en producción se te ha colado una clave de prueba. Y la clave 1x…AA también aceptó un token inventado (response=otro), aunque la documentación dice que las claves secretas de prueba solo aceptan el token ficticio. No construyas tests que dependan de que se rechace.

Errores comunes (y cómo evitarlos)

  • No validar en el servidor. El error más grave y el más frecuente. Sin Siteverify no hay protección.
  • Validar el mismo token dos veces. Por ejemplo, una vez en un middleware y otra en el handler. La segunda devuelve timeout-or-duplicate.
  • Reintentar sin idempotency_key. Si la primera llamada llegó pero se perdió la respuesta, el reintento falla como duplicado.
  • No comprobar hostname ni action. Un token válido emitido en otro formulario o en otra web con tu clave de sitio no debería servir para este.
  • Dejar claves de prueba en producción. Revisa metadata.result_with_testing_key.
  • Exponer la clave secreta. Nunca en el HTML ni en el repositorio; usa secretos.
  • Formularios con SPA. Si renderizas el widget varias veces, usa el renderizado explícito (turnstile.render) y resetea el widget tras cada envío, o el segundo envío llevará un token ya gastado.

Cuándo usar Turnstile (y cuándo no)

Úsalo en registros, logins, formularios de contacto, comentarios, recuperación de contraseña y cualquier acción pública que un bot pueda repetir a escala. Es gratis, no necesita que tu web esté en Cloudflare y respeta mucho más la experiencia del usuario que un CAPTCHA clásico.

No basta contra ataques volumétricos ni contra abusos de una API sin formulario: para eso están el rate limiting y el WAF. Y para APIs consumidas por máquinas legítimas tampoco encaja: ahí autentica con tokens.

Sigue leyendo