> ## Content Index
> Fetch the complete content index at: https://blog.devutilites.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Cloudflare Turnstile a fondo: protege tus formularios de bots sin CAPTCHA
- URL: https://blog.devutilites.com/cloudflare-turnstile-a-fondo/
- Published: 2026-10-03T03:14:02.000Z
- Updated: 2026-10-03T03:14:02.000Z
- Description: 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.
- Author: Luis Collantes
- Tags: Cloudflare, DevOps

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](https://blog.devutilites.com/content/images/2026/10/cf-turnstile-esquema.png)

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

| Modo                      | Qué ve el visitante                                                       |
| ------------------------- | ------------------------------------------------------------------------- |
| **Managed** (recomendado) | Cloudflare decide según el riesgo: casi siempre nada, a veces una casilla |
| **Non-Interactive**       | Un widget con un indicador de carga; nunca tiene que interactuar          |
| **Invisible**             | Nada 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

| Concepto                  | Free       | Enterprise           |
| ------------------------- | ---------- | -------------------- |
| Precio                    | Gratis     | Contactar con ventas |
| Widgets                   | Hasta 20   | Ilimitados           |
| Desafíos y verificaciones | Ilimitados | Ilimitados           |
| Hostnames por widget      | 10         | Hasta 200            |
| Todos los modos de widget | Sí         | 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 secreta                       | Comportamiento            |
| ----------------------------------- | ------------------------- |
| 1x0000000000000000000000000000000AA | Siempre valida            |
| 2x0000000000000000000000000000000AA | Siempre falla             |
| 3x0000000000000000000000000000000AA | Devuelve «token ya usado» |

| Clave de sitio           | Comportamiento                |
| ------------------------ | ----------------------------- |
| 1x00000000000000000000AA | Siempre pasa (visible)        |
| 2x00000000000000000000AB | Siempre falla (visible)       |
| 1x00000000000000000000BB | Siempre pasa (invisible)      |
| 3x00000000000000000000FF | Fuerza 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:

```json
{
  "$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`:

```typescript
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="tu@email.com">
  <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:

```text
$ 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=ana@ejemplo.com -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=ana@ejemplo.com -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=ana@ejemplo.com -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=ana@ejemplo.com -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:

```bash
curl -s https://challenges.cloudflare.com/turnstile/v0/siteverify \
  -d secret=1x0000000000000000000000000000000AA -d response=XXXX.DUMMY.TOKEN.XXXX
```

```json
{"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](https://blog.devutilites.com/cloudflare-waf-ddos-a-fondo/). Y para APIs consumidas por máquinas legítimas tampoco encaja: ahí autentica con tokens.

## Sigue leyendo

- [Cloudflare a fondo](https://blog.devutilites.com/cloudflare-a-fondo/): la visión general de la plataforma.
- [Cloudflare WAF y protección DDoS a fondo](https://blog.devutilites.com/cloudflare-waf-ddos-a-fondo/): reglas, rate limiting y mitigación de ataques.
- [Cloudflare Email a fondo](https://blog.devutilites.com/cloudflare-email-a-fondo/): envía el formulario ya validado por correo.
- [Cloudflare Workers a fondo](https://blog.devutilites.com/cloudflare-workers-a-fondo/): el runtime del validador.