wandres.dev
EMAIL, TURNSTILE, RATE LIMITING · utilidades de borde

Turnstile: la prueba de humanidad sin acertijos

Pedirle a una persona que identifique semáforos en una cuadrícula fue durante una década el peaje universal de internet, y hacía dos cosas mal a la vez: molestaba a los humanos y ya no detenía a las máquinas. Turnstile sustituye el acertijo por un conjunto de señales pasivas del navegador y devuelve un veredicto en forma de token efímero. Vemos el widget en el cliente y sus modos, la verificación del token contra el endpoint de validación desde el Worker, por qué el token es de un solo uso y caduca en cinco minutos, la comprobación de hostname y action que casi nadie hace, y la frontera exacta de lo que esta prueba garantiza y lo que no.

⏱ 16 min

Durante una década el peaje de internet fue el acertijo visual: identifica los semáforos, transcribe el texto borroso, demuestra con el ratón que eres de carne. Ese contrato se rompió por los dos extremos a la vez, porque hoy una máquina resuelve esos acertijos mejor y más barato que tú, mientras el humano legítimo abandona el formulario a la tercera cuadrícula. Turnstile acepta la ruptura y cambia la pregunta: en lugar de exigirte una demostración activa, observa cómo se comporta tu navegador y emite un veredicto. La fricción desaparece del usuario y reaparece donde debía estar desde el principio, en el servidor que decide si creerse el veredicto.

🎯 Al terminar esta lección sabrás
  • Situar Turnstile frente al CAPTCHA clásico y conocer sus modos de widget.
  • Insertar el widget y recoger el token que produce en el formulario.
  • Verificar ese token desde el Worker contra el endpoint de validación.
  • Comprobar hostname y action, y delimitar qué garantiza realmente la prueba.

Dos claves y un token

El montaje tiene tres piezas y conviene nombrarlas bien desde el principio. La sitekey es pública y vive en el HTML: identifica tu widget y no protege nada. La clave secreta es privada, vive como secreto cifrado del Worker y es lo único que autoriza a preguntar por un veredicto. Y entre ambas está el token, una cadena efímera que el widget entrega al navegador y que el navegador te manda a ti.

El ciclo completo es este: el widget evalúa el navegador sin molestar al usuario, produce un token, el formulario lo envía junto al resto de los campos, y tu Worker cambia ese token por un veredicto llamando a la API de validación con la clave secreta. El token, por sí solo, no significa nada hasta que lo canjeas.

sequenceDiagram
participant N as navegador
participant T as widget Turnstile
participant W as Worker
participant S as siteverify
N->>T: carga el widget con la sitekey
T-->>N: token efimero
N->>W: envia el formulario con el token
W->>S: token mas clave secreta
S-->>W: veredicto success mas hostname y action
W-->>N: acepta o rechaza

El widget en el cliente

En su forma más simple es un contenedor con la sitekey y el script de la plataforma. El widget se inyecta solo, resuelve su comprobación y deja el token en un campo oculto del formulario llamado cf-turnstile-response, que viaja con el envío sin que tú tengas que tocarlo.

<form method="POST" action="/contacto">
  <input type="email" name="correo" required />
  <textarea name="mensaje" required></textarea>

  <div class="cf-turnstile"
       data-sitekey="0x4AAAAAAA_clave_publica"
       data-action="contacto"
       data-theme="auto"></div>

  <button type="submit">Enviar</button>
</form>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

Hay tres modos y la elección es de producto, no de seguridad. El gestionado deja que la plataforma decida si hace falta una interacción, y es el buen valor por defecto. El no interactivo nunca pide nada al usuario pero sigue mostrando el widget, útil cuando quieres que se vea la señal de que hay una comprobación. El invisible no muestra nada en absoluto, ideal para formularios muy cuidados, a costa de que el usuario no tenga ninguna explicación visual si algo falla.

En una aplicación de una sola página el montaje implícito estorba, porque el widget debe aparecer y renovarse en momentos que decide tu código. Para eso existe el renderizado explícito: cargas la librería, esperas a que esté lista y montas el widget sobre un contenedor cuando toca, recogiendo el token en una función de retorno en lugar de en un campo del formulario. Es más trabajo, y a cambio te devuelve el control sobre el ciclo de vida, que es justo lo que necesitas para poder refrescar un token caducado sin recargar la página.

💡
Nombra la acción y aprovecha el contexto

El atributo data-action etiqueta para qué se emitió el token, y data-cdata te deja adjuntar un dato propio, como el identificador de la sesión. Ambos vuelven en la respuesta de validación. Sin ellos, un token obtenido en tu formulario de contacto vale exactamente igual en tu endpoint de registro, porque el servidor no tiene forma de distinguirlos. Etiquetar es lo que convierte una prueba genérica en una prueba de algo concreto.

La verificación en el Worker

Todo lo anterior es decorado hasta este punto. La validación es una petición POST con la clave secreta y el token, y su respuesta es el único juicio que importa.

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

async function verificar(env: Env, token: string, ip: string): Promise<Veredicto> {
  const cuerpo = new FormData();
  cuerpo.append("secret", env.TURNSTILE_SECRET);
  cuerpo.append("response", token);
  cuerpo.append("remoteip", ip);
  cuerpo.append("idempotency_key", crypto.randomUUID());

  const r = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
    method: "POST",
    body: cuerpo,
  });
  return await r.json<Veredicto>();
}

export default {
  async fetch(request, env): Promise<Response> {
    const form = await request.formData();
    const token = String(form.get("cf-turnstile-response") ?? "");
    const ip = request.headers.get("CF-Connecting-IP") ?? "";

    const v = await verificar(env, token, ip);
    const valido = v.success && v.hostname === "ejemplo.com" && v.action === "contacto";

    if (!valido) {
      return new Response("Verificacion fallida", { status: 403 });
    }
    return new Response("Mensaje recibido", { status: 202 });
  },
} satisfies ExportedHandler<Env>;

Mirar solo success es el error habitual, y es el que anula la protección en la práctica. Un token es válido para el sitio que lo emitió, así que sin comprobar hostname aceptas tokens generados en un dominio ajeno con tu misma sitekey copiada. Sin comprobar action, aceptas en tu endpoint caro un token cosechado del formulario más barato de tu web.

⏱️

Cinco minutos

El token caduca pronto. Un formulario que el usuario deja abierto media hora fallará al enviarse, y tu interfaz debe saber refrescar el widget.

🎫

Un solo uso

Canjear el mismo token dos veces devuelve timeout-or-duplicate. La clave de idempotencia existe para tus reintentos legítimos, no para reutilizarlo.

🏷️

Comprueba hostname y action

Sin esas dos comprobaciones el veredicto responde a una pregunta más débil que la que tú creías estar haciendo.

🕵️

Lee los códigos de error

Distinguir un secreto mal configurado de un token caducado o duplicado es la diferencia entre depurar en minutos o en días.

Lo que prueba y lo que no

Conviene ser preciso con el alcance, porque la confusión aquí produce arquitecturas falsamente tranquilas. Turnstile aporta evidencia probabilística de que el envío viene de un navegador real gobernado por una persona, en el momento de emitir el token. Eso es todo, y es mucho.

No dice quién es esa persona, así que no sustituye a la autenticación. No impide que un humano real haga cien envíos manuales, así que no sustituye al límite de tasa. No valida el contenido del formulario, así que no sustituye a la sanitización. Y no resiste a un adversario dispuesto a pagar a personas por resolver comprobaciones, porque contra eso ninguna prueba de humanidad puede nada por definición: hay humanos de verdad al otro lado.

La prueba se desplazó del usuario a la máquina, y esa es toda la idea

Lo que hace Turnstile intelectualmente interesante no es que sea cómodo, sino dónde decidió poner el trabajo. El CAPTCHA clásico nació de una intuición honesta de principios de siglo, que había tareas fáciles para cualquier persona e imposibles para cualquier programa, y durante unos años esa asimetría fue real y sostuvo media internet. Su final no llegó por un fallo de implementación sino porque la asimetría se invirtió: la visión por computador superó a la humana en exactamente esas tareas, de modo que el acertijo acabó filtrando al revés, castigando a la abuela con cataratas y dejando pasar al bot. La respuesta correcta a esa inversión no era hacer los acertijos más difíciles, camino que solo agrava el daño colateral, sino admitir que la pregunta estaba mal formulada. Turnstile no pregunta si sabes resolver algo, observa cómo se comporta el entorno que envía la petición, y eso traslada el esfuerzo desde el usuario, que es el único participante inocente de toda la escena, hacia la máquina, que es donde debía estar desde el principio. Pero el desplazamiento tiene un precio conceptual que hay que aceptar con los ojos abiertos: el veredicto deja de ser una demostración y pasa a ser una estimación, un número de confianza vestido de booleano. success en verdadero no significa que haya un humano, significa que las señales observadas son compatibles con que lo haya. Quien entiende esto construye en consecuencia: no monta una puerta sino un factor, no lo usa solo sino compuesto con límites de tasa e identidad, comprueba hostname y action porque sabe que está autorizando un contexto y no una persona, y sobre todo diseña el camino del falso positivo, porque una prueba probabilística se equivocará con usuarios reales y lo que ocurra en ese instante define si has construido una defensa o un muro contra tus propios clientes.

⚔️ Emite un token y no te fíes de él
  1. Monta un formulario con el widget gestionado y un Worker que valide el token. Confirma que el campo cf-turnstile-response llega con el resto de los campos.
  2. Envía el formulario sin token y con un token inventado. Registra los códigos de error devueltos y distingue cada caso en tu respuesta.
  3. Canjea el mismo token dos veces y observa el error de duplicado. Explica en qué situación real la clave de idempotencia es la solución correcta.
  4. Cosecha un token en un formulario con una action y úsalo contra un endpoint con otra. Añade la comprobación de action y verifica que ahora se rechaza.
  5. Diseña la ruta del falso positivo: qué ve un usuario legítimo cuyo token caduca o falla, y cómo vuelve a intentarlo sin perder lo que había escrito.