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.
Contenido
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.
Cómo funciona
El flujo tiene cuatro pasos y el último es el que más gente se salta:
- Tu página carga el script de Turnstile y muestra el widget con tu clave de sitio (sitekey, pública).
- 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. - El navegador envía el formulario con el token a tu servidor.
- Tu servidor llama a la API Siteverify con el token y tu clave secreta. Solo si responde
success: trueaceptas la petición.

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:
{
"$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
hostnameniaction. 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
- Cloudflare a fondo: la visión general de la plataforma.
- Cloudflare WAF y protección DDoS a fondo: reglas, rate limiting y mitigación de ataques.
- Cloudflare Email a fondo: envía el formulario ya validado por correo.
- Cloudflare Workers a fondo: el runtime del validador.