¿Por qué un sitio con Cloudflare devuelve HTTP 503 con "Just a moment..." en vez del contenido? Porque se ha lanzado una página de challenge: un flujo de token que arranca con parámetros ocultos en el HTML, pasa por un proof-of-work en JavaScript y acaba en la cookie qa_validation_cookie, la que autoriza el acceso.
Conocer ese flujo te deja depurar por qué una resolución falla, elegir entre el método cloudflare_challenge y turnstile, y saber cuándo delegar el proceso en un servicio de resolución.
Pasa a diario: un equipo en Madrid o Ciudad de México hace QA de su flujo sobre un portal público con Cloudflare —una cita previa o un marketplace— y ve cada petición cortada por un 503. Respeta siempre los términos de servicio del sitio.
Anatomía de una página de Cloudflare Challenge
Una página de challenge responde con HTTP 503 y trae varios elementos clave:
<!DOCTYPE html>
<html>
<head>
<title>Just a moment...</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
<div id="challenge-stage">
<div id="challenge-body-text">
Checking if the site connection is secure
</div>
<div id="challenge-spinner">
<!-- Loading spinner -->
</div>
</div>
<div id="challenge-form" style="display:none">
<form id="challenge-form" action="/..." method="POST">
<!-- Hidden parameters -->
<input type="hidden" name="md" value="...">
<input type="hidden" name="r" value="...">
</form>
</div>
<script src="/cdn-cgi/challenge-platform/h/g/orchestrate/chl_page/v1?ray=...">
</script>
</body>
</html>
El <script> de /cdn-cgi/challenge-platform/ es el que mueve el flujo.
Parámetros clave del challenge
Los parámetros importantes viven en tres sitios. Primero, los del HTML:
| Parámetro | Nombre | Para qué sirve |
|---|---|---|
ray |
Ray ID de Cloudflare | Identificador único de la solicitud; vincula el challenge con la petición original |
md |
Metadatos del challenge | Estado del challenge cifrado |
r |
Token de respuesta | Respuesta calculada (la rellena el JavaScript) |
chl_opt |
Opciones del challenge | Configuración para el script del challenge |
cRay |
Ray del challenge | Ray secundario para el seguimiento del challenge |
cZone |
Zona del challenge | ID de zona de Cloudflare |
cUPMDTk |
Marca de tiempo | Momento de emisión del challenge |
cHash |
Hash del challenge | Validación de integridad |
La URL del script codifica la versión y el Ray ID en su ruta:
/cdn-cgi/challenge-platform/h/g/orchestrate/chl_page/v1?ray=ABC123
| Componente | Significado |
|---|---|
/cdn-cgi/challenge-platform/ |
Infraestructura del challenge de Cloudflare |
h/g/ |
Versión/variante del challenge |
orchestrate/ |
Endpoint de orquestación del challenge |
chl_page/v1 |
Versión de la página del challenge |
ray=ABC123 |
Vínculo con el Ray ID de la solicitud |
En ejecución, el script inyecta el objeto _cf_chl_opt:
// Extracted from obfuscated challenge script
window._cf_chl_opt = {
cvId: '2', // Challenge version
cType: 'managed', // Challenge type
cNounce: '...', // Cryptographic nonce
cRay: '...', // Challenge Ray ID
cHash: '...', // Challenge hash
cUPMDTk: '...', // Timestamp
cFPWv: 'g', // Fingerprint version
cTTimeMs: '4000', // Minimum wait time (ms)
cTplV: 5, // Template version
cLt: '...', // Challenge lifetime
cRq: {}, // Challenge request data
};
Al depurar, mira sobre todo cType (managed, interactivo o Turnstile) y cTTimeMs, el tiempo mínimo de espera.
Del challenge a la autorización: el flujo del token
Este es el ciclo completo, desde la primera petición hasta que se sirve el contenido:
1. CLIENT → CLOUDFLARE EDGE
GET /protected-page
↓
2. CLOUDFLARE → CLIENT
HTTP 503 + Challenge page HTML
Sets: __cf_bm cookie (bot management tracking)
Contains: ray ID, challenge script URL
↓
3. CLIENT (browser)
Loads challenge script from /cdn-cgi/challenge-platform/...
↓
4. CHALLENGE SCRIPT EXECUTES:
a. Collects browser fingerprint:
- Canvas hash
- WebGL renderer
- Screen dimensions
- Installed fonts
- Timezone
- Language
b. Runs proof-of-work:
- Iterates hash computations
- Must find answer matching difficulty
c. Computes timing:
- Enforces minimum wait (cTTimeMs)
- Records actual timing
d. Generates response token:
- Combines fingerprint + PoW answer + timing
- Encrypts with challenge nonce
↓
5. CLIENT → CLOUDFLARE
POST /cdn-cgi/challenge-platform/h/g/flow/ov1/...
Body: { r: "encrypted_response", md: "metadata", ... }
↓
6. CLOUDFLARE validates:
- Proof-of-work answer correct?
- Timing within acceptable range?
- Fingerprint consistent with real browser?
- No replay (nonce check)?
↓
7. CLOUDFLARE → CLIENT
HTTP 200 + Set-Cookie: qa_validation_cookie=...; path=/; expires=...
+ HTTP redirect to original URL
↓
8. CLIENT → CLOUDFLARE
GET /protected-page
Cookie: qa_validation_cookie=...
↓
9. CLOUDFLARE → CLIENT
HTTP 200 + Protected content
La clave está en el paso 4: saltarse el fingerprint, el proof-of-work o el tiempo mínimo te devuelve al challenge. Las cookies siguen su propio calendario:
Request 1: No cookies
→ Challenge page (503)
→ __cf_bm cookie set
Challenge solve:
→ qa_validation_cookie cookie set
Request 2+: qa_validation_cookie + __cf_bm
→ Content served (200)
After ~30 mins: qa_validation_cookie expires
→ Next request triggers new challenge
Cookies del challenge y sus restricciones
Cloudflare deja varias cookies, pero solo una autoriza el acceso:
| Cookie | Para qué sirve | Duración | Alcance |
|---|---|---|---|
__cf_bm |
Seguimiento de sesión del bot management | 30 min | Dominio |
qa_validation_cookie |
Prueba de que superaste el challenge | 15 min – 24 h (configurable) | Dominio |
__cflb |
Afinidad del balanceador de carga | Sesión | Dominio |
_cfuvid |
ID de visitante único | Sesión | Dominio |
La qa_validation_cookie no es un token portable: queda atada a la IP que resolvió el challenge, al User-Agent de esa resolución y al dominio que la emitió. Cambiar cualquiera de los tres la invalida:
# ❌ FAILS — IP mismatch
# Solve challenge from IP A, then use qa_validation_cookie from IP B
# ❌ FAILS — UA mismatch
# Solve with Chrome UA, then send requests with Firefox UA
# ✅ WORKS — Same IP + Same UA
session = requests.Session()
session.headers["User-Agent"] = "Mozilla/5.0 ... Chrome/120.0.0.0"
# Use same session for solving and subsequent requests
Regla práctica: resuelve y consume el contenido en la misma sesión y salida de red; cambiarla a mitad del flujo provoca la mayoría de los 403 posteriores.
Cómo extraer los parámetros del challenge
Para ver qué challenge devuelve un sitio, lee los parámetros del HTML del 503, en Python y en Node.js:
import re
import requests
def extract_challenge_params(url):
"""Extract Cloudflare challenge page parameters."""
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/120.0.0.0",
"Accept": "text/html,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
}
response = requests.get(url, headers=headers, timeout=15, allow_redirects=False)
html = response.text
params = {
"status_code": response.status_code,
"cf_ray": response.headers.get("cf-ray", ""),
"is_challenge": response.status_code == 503,
}
if not params["is_challenge"]:
return params
# Extract Ray ID from page
ray_match = re.search(r"ray['\"]?\s*[:=]\s*['\"]([a-f0-9]+)['\"]", html, re.I)
if ray_match:
params["ray_id"] = ray_match.group(1)
# Extract challenge script URL
script_match = re.search(
r'src=["\'](/cdn-cgi/challenge-platform/[^"\']+)["\']', html
)
if script_match:
params["challenge_script"] = script_match.group(1)
# Extract challenge options
opt_match = re.search(r"_cf_chl_opt\s*=\s*\{([^}]+)\}", html)
if opt_match:
opt_text = opt_match.group(1)
# Parse individual options
for key in ["cType", "cRay", "cHash", "cTTimeMs", "cvId", "cFPWv"]:
val_match = re.search(
rf"{key}\s*:\s*['\"]?([^'\"', }}]+)", opt_text
)
if val_match:
params[key] = val_match.group(1)
# Extract form parameters
md_match = re.search(r'name=["\']md["\']\s+value=["\']([^"\']+)["\']', html)
if md_match:
params["md"] = md_match.group(1)
# Extract cookies from response
params["cookies"] = {
name: value
for name, value in response.cookies.items()
}
return params
# Usage
params = extract_challenge_params("https://protected-site.com")
if params["is_challenge"]:
print(f"Challenge type: {params.get('cType', 'unknown')}")
print(f"Ray ID: {params.get('ray_id', params['cf_ray'])}")
print(f"Min wait: {params.get('cTTimeMs', '?')}ms")
print(f"Script: {params.get('challenge_script', 'not found')}")
const axios = require("axios");
async function extractChallengeParams(url) {
const response = await axios.get(url, {
headers: {
"User-Agent": "Mozilla/5.0 Chrome/120.0.0.0",
Accept: "text/html,*/*;q=0.8",
},
validateStatus: () => true,
maxRedirects: 0,
});
const html = response.data;
const params = {
statusCode: response.status,
cfRay: response.headers["cf-ray"] || "",
isChallenge: response.status === 503,
};
if (!params.isChallenge) return params;
// Extract challenge script URL
const scriptMatch = html.match(
/src=["'](\/cdn-cgi\/challenge-platform\/[^"']+)["']/
);
if (scriptMatch) params.challengeScript = scriptMatch[1];
// Extract challenge type
const typeMatch = html.match(/cType\s*:\s*['"]?(\w+)/);
if (typeMatch) params.challengeType = typeMatch[1];
// Extract timing
const timeMatch = html.match(/cTTimeMs\s*:\s*['"]?(\d+)/);
if (timeMatch) params.minWaitMs = parseInt(timeMatch[1]);
return params;
}
extractChallengeParams("https://protected-site.com").then(console.log);
Esto diagnostica, pero no resuelve: el token final solo aparece al ejecutar el JavaScript en un navegador real.
Resolver el Cloudflare Challenge con CaptchaAI
En lugar de reproducir el proof-of-work a mano, envías la URL con el método cloudflare_challenge a in.php y sondeas el resultado en res.php:
import requests
import time
API_KEY = "YOUR_API_KEY"
def solve_cloudflare_challenge(target_url):
"""Solve Cloudflare challenge page — CaptchaAI handles token flow."""
submit = requests.post("https://ocr.captchaai.com/in.php", data={
"key": API_KEY,
"method": "cloudflare_challenge",
"sitekey": "managed",
"pageurl": target_url,
"json": 1,
})
task_id = submit.json()["request"]
for _ in range(60):
time.sleep(5)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": API_KEY,
"action": "get",
"id": task_id,
"json": 1,
}).json()
if result.get("status") == 1:
return result["request"]
raise TimeoutError("Challenge solve timed out")
# CaptchaAI handles the full flow:
# 1. Loads the challenge page
# 2. Executes JavaScript
# 3. Solves proof-of-work
# 4. Returns clearance token/cookies
token = solve_cloudflare_challenge("https://protected-site.com/login")
CaptchaAI usa navegadores reales, así que no reimplementas el fingerprint ni el temporizador. Los planes van por thread con resoluciones ilimitadas: empieza en BASIC ($15/mes, 5 threads).
Depurar fallos en el challenge
El fallo casi siempre es uno de estos puntos, con su solución habitual:
| Punto de fallo | Síntoma | Causa raíz |
|---|---|---|
| La página del challenge no carga | Timeout o respuesta vacía | Problema de red/proxy |
| El script no se ejecuta | El challenge entra en bucle | Faltan APIs de JavaScript |
| El proof-of-work falla | Spinner infinito | Timeout de cómputo |
| Respuesta rechazada | Redirige de vuelta al challenge | Violación de tiempo o señales del navegador que no cuadran |
qa_validation_cookie no se establece |
Falta la cookie tras resolver | Error al parsear la respuesta |
qa_validation_cookie rechazada |
403 en la siguiente solicitud | La IP o la UA no coinciden |
| Síntoma | Causa | Solución |
|---|---|---|
| El tipo de challenge es "managed" pero la resolución falla | El challenge requiere Turnstile, no el challenge de JS | Prueba el método turnstile en lugar de cloudflare_challenge |
qa_validation_cookie funciona una vez y luego la rechazan |
La rotación de IP cambió tu IP | Fija la IP durante la vida útil del token |
| La página "Just a moment..." no se resuelve nunca | JavaScript bloqueado o mal formado | Usa CaptchaAI en lugar de resolver a mano |
| El challenge reaparece tras cada solicitud | No se está enviando qa_validation_cookie |
Asegúrate de que las cookies persisten en la sesión |
| Challenges distintos en rutas distintas | Reglas WAF por ruta | Resuelve cada ruta por separado |
Este ayudante señala dónde se rompe el flujo:
def debug_challenge_flow(url, qa_validation_cookie_cookie=None, user_agent=None):
"""Debug the challenge solve flow step by step."""
ua = user_agent or (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/120.0.0.0"
)
steps = []
# Step 1: Initial request
response = requests.get(
url,
headers={"User-Agent": ua, "Accept": "text/html,*/*;q=0.8"},
timeout=15,
allow_redirects=False,
)
steps.append({
"step": "initial_request",
"status": response.status_code,
"is_challenge": response.status_code == 503,
"cf_ray": response.headers.get("cf-ray", ""),
})
# Step 2: Test with qa_validation_cookie
if qa_validation_cookie_cookie:
session = requests.Session()
session.cookies.set("qa_validation_cookie", qa_validation_cookie_cookie)
session.headers["User-Agent"] = ua
response2 = session.get(url, timeout=15, allow_redirects=False)
steps.append({
"step": "with_clearance",
"status": response2.status_code,
"passed": response2.status_code == 200,
})
if response2.status_code != 200:
steps.append({
"step": "diagnosis",
"issue": "qa_validation_cookie rejected",
"possible_causes": [
"Cookie expired",
"IP address changed",
"User-Agent mismatch",
"Cookie from different domain",
],
})
return steps
Preguntas frecuentes
¿Qué diferencia hay entre __cf_bm y la cookie qa_validation_cookie?
__cf_bm es de seguimiento del bot management y no autoriza el acceso. La qa_validation_cookie se emite al superar el challenge y es la que abre el contenido; necesitas ambas en las peticiones siguientes.
¿Uso el método cloudflare_challenge o turnstile?
Depende del tipo. Si ves "Just a moment..." con proof-of-work, es el challenge de JS: usa cloudflare_challenge. Si es un widget con casilla, es Turnstile: usa turnstile.
¿Por qué mi token deja de funcionar al cambiar de servidor o de proxy?
Porque está atada a la IP y al User-Agent que resolvieron el challenge. Enviarla desde otra IP o UA hace que Cloudflare la invalide con un 403. Usa una única sesión de extremo a extremo.
¿Cuánto tiempo puedo reutilizar el token antes de resolver otro challenge?
Lo fija el operador del sitio: por defecto ronda los 30 minutos, en un rango de 15 minutos a 24 horas. Dentro de esa ventana puedes reutilizarlo; al expirar, la siguiente petición lanza un challenge nuevo.
Resumen
El Cloudflare Challenge encadena Ray IDs, el script de /cdn-cgi/challenge-platform/, el objeto _cf_chl_opt y parámetros de formulario en un flujo con proof-of-work que termina en la cookie qa_validation_cookie, atada a la IP y al User-Agent. Con CaptchaAI no parseas nada a mano: el solver gestiona el flujo completo, y conocer los parámetros te indica dónde se rompe al depurar.
Artículos relacionados
- Cloudflare Challenge frente a Turnstile: cómo distinguirlos
- Cloudflare: challenge gestionado frente a interactivo
- Error 403 tras el token de Cloudflare Turnstile: cómo solucionarlo