OpenWAF Verify es un widget que insertas en un formulario. El navegador del visitante resuelve una prueba de trabajo breve y obtiene un token de un solo uso; tu servidor envía ese token a siteverify antes de aceptar el formulario. Usa el mismo motor que el reto de navegador.
Todo es de primera parte: el script, su worker y sus endpoints los sirve el borde de OpenWAF bajo /__waf/ en tu propio nombre con proxy. No hay iframe de terceros ni cookie de terceros, y el widget funciona con Content-Security-Policy: default-src 'self' (sin script ni estilo en línea, sin eval, sin blob:).
Demo en vivo: developers.openwaf.dev/demo/verify/ ejecuta este widget en este mismo sitio, detrás del borde de OpenWAF, con default-src 'self'.
El nombre que muestra el widget tiene que pasar por el proxy de OpenWAF.
Cómo funciona
- Crea un widget para tu zona. Obtienes una site key pública (
owk_…) y un secreto (ows_…), que se muestra una sola vez. - Añade el script y un contenedor con la site key a tu página.
- El widget pide un reto al borde, lo resuelve en un Web Worker y pone el token en un campo oculto
owaf-verify-responsedel formulario. - Tu servidor envía el token y el secreto a siteverify y solo acepta el formulario cuando la respuesta trae
"success": true.
Crear un widget
Un widget tiene un nombre (1–64 caracteres), un modo y de 1 a 10 nombres de la zona (el ápice o nombres bajo él, sin comodines) donde se puede mostrar. Una zona puede tener hasta 20 widgets.
Dominio → Seguridad → Verificación → Widgets → Añadir widget. El secreto aparece una vez en un diálogo con botón de copiar: guárdalo antes de cerrarlo.
openwaf-cloud ctl challenge widget-add example.com --name "Formulario de alta" --mode managed \
--hostname example.com --hostname www.example.com --reason "Proteger el alta"El secreto se imprime una vez en la salida estándar.
curl -s -X POST https://dash.openwaf.dev/v1/orgs/$ORG/zones/$ZONE/challenge/widgets \
-H "Authorization: Bearer $OPENWAF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Formulario de alta", "mode": "managed", "hostnames": ["example.com", "www.example.com"], "reason": "Proteger el alta"}'La respuesta es 201 con {"widget": {…}, "secret": "ows_…"}. Añade ?dry_run=1 para validar sin crear nada.
challenge_widgets con {"zone": "example.com", "action": "create", "name": "Formulario de alta", "mode": "managed", "hostnames": ["example.com"], "reason": "Proteger el alta"}, primero confirm=false y después confirm=true. El resultado confirmado trae el secreto una sola vez.
Añadirlo a tu página
<script src="/__waf/challenge/v1/widget.js" async defer></script>
<form action="/signup" method="post">
<input name="email" type="email" required>
<div class="owaf-verify" data-sitekey="owk_TU_SITE_KEY" data-action="signup"></div>
<button type="submit">Darme de alta</button>
</form>El script busca todos los elementos .owaf-verify y muestra un widget en cada uno. Cuando el navegador ha resuelto el reto, el contenedor tiene <input type="hidden" name="owaf-verify-response" value="v1.…">, que el formulario envía con el resto de campos.
| Atributo | Valores | Significado |
|---|---|---|
data-sitekey | owk_… | Obligatorio. Site key de un widget cuyos nombres incluyen este. |
data-action | 0–32 de A-Z a-z 0-9 _ - | Etiqueta que devuelve siteverify, para que tu servidor compruebe que el token se hizo para este formulario. |
data-cdata | hasta 255 caracteres ASCII imprimibles | Datos tuyos, que devuelve siteverify. |
data-callback | nombre de función | Se llama con el token si todo va bien. |
data-error-callback | nombre de función | Se llama con un código de error cuando el widget falla. |
data-expired-callback | nombre de función | Se llama cuando el token caduca antes de usarse. |
data-theme | auto (por defecto), light, dark | Colores de la caja. |
data-language | auto (por defecto), en, es | Idioma de la caja; auto sigue al navegador. |
data-response-field-name | nombre de campo | Nombre del campo oculto. Por defecto owaf-verify-response. |
data-refresh-expired | auto (por defecto), manual | auto resuelve un reto nuevo antes de que caduque el token y lo anuncia; manual llama en su lugar al callback de caducidad. |
API de JavaScript
Carga el script con ?render=explicit para mostrar tú los widgets, y con onload=<función> para que te avise cuando esté listo:
<script src="/__waf/challenge/v1/widget.js?render=explicit&onload=onVerifyReady" async defer></script>function onVerifyReady() {
const id = window.owafVerify.render("#verify", {
sitekey: "owk_TU_SITE_KEY",
action: "login",
callback: (token) => console.log("listo"),
});
}| Método | Efecto |
|---|---|
owafVerify.render(elementoOSelector, opciones) | Muestra un widget y devuelve su id. Las opciones usan los nombres de los atributos sin data- (sitekey, action, cdata, callback…). |
owafVerify.execute(id) | Empieza el reto. Obligatorio en los widgets invisible. |
owafVerify.reset(id) | Descarta el token y vuelve a empezar. Llámalo tras un envío fallido: un token vale una vez. |
owafVerify.getResponse(id) | Devuelve el token actual, o una cadena vacía. |
owafVerify.isExpired(id) | Si el token actual ha caducado. |
owafVerify.remove(id) | Quita el widget. |
Modos
| Modo | Qué ve el visitante | Interacción |
|---|---|---|
managed | Una caja de 300×65 con una línea de estado | Solo cuando el navegador dice que está automatizado (navigator.webdriver): la caja muestra un botón «Soy una persona» con el nombre de tu host y un reto más duro. |
non_interactive | Una caja de 300×65 con una línea de estado | Nunca. Un navegador automatizado recibe el error interaction-required. |
invisible | Nada | Nunca. Llama a execute(id), por ejemplo al enviar el formulario. Un navegador automatizado recibe interaction-required. |
En esta versión la única señal que pide interacción es navigator.webdriver. Las señales de riesgo del lado del servidor llegarán en una versión posterior. La línea de estado es una región viva que leen los lectores de pantalla, y la caja se alcanza con el teclado.
Tokens
- Un token empieza por
v1., tiene como mucho 2048 caracteres y vale 300 segundos. - Es de un solo uso: la primera llamada a siteverify lo gasta en todos los nodos de borde y en el plano de control; una segunda recibe
timeout-or-duplicate. - Va ligado a la red del visitante (/24 IPv4, /64 IPv6) y lleva el nombre del host, la acción y el cdata.
Comprobar el token en tu servidor
Envía el token a siteverify desde tu servidor, nunca desde el navegador. Puedes llamarlo en tu propio host, a través del borde, o en el plano de control:
curl -s https://example.com/__waf/challenge/siteverify \
-d secret="$OWAF_VERIFY_SECRET" \
-d response="$TOKEN" \
-d remoteip="203.0.113.7"{"success": true, "challenge_ts": "2026-10-28T10:15:03Z", "hostname": "example.com",
"action": "signup", "error-codes": [], "metadata": {"mode": "managed", "interactive": false, "risk": "low"}}Comprueba success, después hostname y action. Los campos, los códigos de error, los límites y la idempotencia están en la referencia de siteverify.
Ejemplos
export async function POST(req: Request) {
const form = await req.formData();
const res = await fetch("https://dash.openwaf.dev/v1/challenge/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: process.env.OWAF_VERIFY_SECRET,
response: form.get("owaf-verify-response"),
remoteip: req.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
}),
});
const result = await res.json();
if (!result.success || result.action !== "signup") {
return Response.json({ error: "verification failed" }, { status: 403 });
}
// crear la cuenta
return Response.json({ ok: true });
}<?php
$ch = curl_init('https://dash.openwaf.dev/v1/challenge/siteverify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => http_build_query([
'secret' => getenv('OWAF_VERIFY_SECRET'),
'response' => $_POST['owaf-verify-response'] ?? '',
'remoteip' => $_SERVER['REMOTE_ADDR'],
]),
]);
$result = json_decode(curl_exec($ch), true);
if (empty($result['success']) || $result['action'] !== 'signup') {
http_response_code(403);
exit('Verificación fallida');
}
// crear la cuentacurl -s https://dash.openwaf.dev/v1/challenge/siteverify \
-H "Content-Type: application/json" \
-d '{"secret": "ows_test_pass", "response": "v1.TEST.TOKEN"}'Detrás del borde de OpenWAF, la dirección de cliente que tu origen ve en REMOTE_ADDR es la del borde; toma la del visitante de la cabecera en la que confía tu origen, o no envíes remoteip.
Claves de prueba
Estas claves funcionan en cualquier host con OpenWAF Verify y en los dos endpoints de siteverify. Nunca gastan un contador ni producen un token real.
| Clave | Comportamiento |
|---|---|
owk_test_pass | Site key: siempre acierta con el token v1.TEST.TOKEN. |
owk_test_fail | Site key: siempre falla con el error test-fail. |
owk_test_interactive | Site key: siempre muestra el paso «Soy una persona» y después da v1.TEST.TOKEN. |
ows_test_pass | Secreto: cualquier respuesta no vacía acierta, con metadata.result_with_testing_key: true. |
ows_test_fail | Secreto: siempre invalid-input-response. |
ows_test_spent | Secreto: siempre timeout-or-duplicate. |
Gestionar widgets
| Tarea | Panel | CLI | API | MCP |
|---|---|---|---|---|
| Listar | Seguridad → Verificación → Widgets | challenge widgets <zona> | GET …/zones/{zone}/challenge/widgets | challenge_widgets action: list |
| Cambiar nombre, modo o nombres | Editar | challenge widget-set <zona> <id> [--name] [--mode] [--hostname…] --reason | PATCH …/challenge/widgets/{widget} | action: update |
| Rotar el secreto | Rotar secreto | challenge rotate-secret <zona> <id> --reason | POST …/challenge/widgets/{widget}/rotate-secret | action: rotate_secret |
| Borrar | Borrar | challenge widget-rm <zona> <id> --reason | DELETE …/challenge/widgets/{widget} | action: delete |
Leer necesita un token readonly; los cambios necesitan tech y un motivo. Los widgets no forman parte de la configuración versionada de la zona: los cambios quedan en el registro de auditoría de la organización (challenge.widget.create, update, delete, rotate_secret), nunca con el secreto, y revertir una versión de la zona no los toca. Toda escritura acepta ?dry_run=1.
Rotar el secreto
Rotar devuelve un secreto nuevo una sola vez. El secreto anterior sigue valiendo 24 horas, para que despliegues el nuevo sin verificaciones fallidas. Tras una segunda rotación dentro de esas 24 horas, solo valen los dos secretos más recientes.
Migrar desde Cloudflare Turnstile
Los campos de siteverify y la mayoría de códigos de error se llaman igual, así que migrar es sobre todo renombrar:
| Turnstile | OpenWAF Verify |
|---|---|
https://challenges.cloudflare.com/turnstile/v0/api.js | /__waf/challenge/v1/widget.js |
class="cf-turnstile" | class="owaf-verify" |
cf-turnstile-response | owaf-verify-response |
window.turnstile.render/execute/reset/getResponse/remove/isExpired | window.owafVerify.… (los mismos métodos) |
https://challenges.cloudflare.com/turnstile/v0/siteverify | https://<tu host>/__waf/challenge/siteverify o https://dash.openwaf.dev/v1/challenge/siteverify |
secret, response, remoteip, idempotency_key | los mismos |
Después, quita challenges.cloudflare.com de tu Content Security Policy: no se carga nada de otro origen.
Datos tratados
El widget está hecho para comprobar que un navegador hizo el trabajo, no para identificar a la persona:
- Prefijo de red. El borde usa la /24 IPv4 o la /64 IPv6 del visitante para ligar el reto y el token. Se mantiene en memoria y en el token solo entra un hash.
navigator.webdriver. Si el navegador dice que está automatizado.- Interacción. Si hubo interacción con la página antes de la comprobación: solo sí o no, nunca movimientos ni teclas.
- Sin huella digital. Sin canvas, WebGL, audio, fuentes, plugins ni client hints de alta entropía, y sin identificador persistente.
- Sin cookies. El widget no pone ninguna cookie ni hace peticiones a otro dominio.
- Dónde. Los retos y los tokens se emiten en nodos de borde de OpenWAF en la UE (Alemania y España).
Las respuestas de siteverify nunca incluyen estas señales. Los recuentos agregados (retos emitidos, resueltos, fallidos; resultados de siteverify) aparecen en la analítica de tu zona.