# Widget OpenWAF Verify

> Protege un formulario o una llamada a la API con un widget de prueba de trabajo servido desde tu propio host y comprueba su token en tu servidor con siteverify.

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](https://developers.openwaf.dev/es/waf/siteverify/) antes de aceptar el formulario. Usa el mismo motor que el [reto de navegador](https://developers.openwaf.dev/es/waf/browser-challenge/).

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/](https://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

1. Crea un widget para tu zona. Obtienes una **site key** pública (`owk_…`) y un **secreto** (`ows_…`), que se muestra una sola vez.
2. Añade el script y un contenedor con la site key a tu página.
3. El widget pide un reto al borde, lo resuelve en un Web Worker y pone el token en un campo oculto `owaf-verify-response` del formulario.
4. 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](#modes) 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.

**Panel**

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.
**CLI**

```sh
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.
**API**

```sh
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.
**MCP**

`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.


> **Atención: El secreto se muestra una vez**
>
> OpenWAF solo guarda un hash del secreto. Si lo pierdes, [rótalo](#rotate). Guárdalo solo en tu servidor; nunca lo pongas en la página.


## Añadirlo a tu página

```html
<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-accent` | `#rrggbb` o `#rgb` | Color de acento de este widget. Por defecto, el color del reto de tu zona. Ver [Aspecto](#appearance). |
| `data-size` | `normal` (por defecto), `compact` | `normal` mide 300×65; `compact`, 150×140. |
| `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:

```html
<script src="/__waf/challenge/v1/widget.js?render=explicit&onload=onVerifyReady" async defer></script>
```

```js
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 con una línea de estado | Solo cuando el navegador dice que está automatizado (`navigator.webdriver`): la caja muestra «Verifica que eres humano» con un botón «Soy humano» que lleva el nombre de tu host, y un reto más duro. |
| `non_interactive` | Una caja 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.

## Aspecto

La caja sigue a `data-theme` y `data-language`, y muestra un icono por estado:

| Estado | Icono | Texto (español) |
|---|---|---|
| Resolviendo | Un anillo que gira en el color de acento (un anillo quieto si el visitante pide movimiento reducido) | «Verificando…», y «Seguimos verificando…» pasados 4 segundos |
| Correcto | Una marca verde que se dibuja con una animación corta (sin animación con movimiento reducido) | «Verificado» |
| Interacción | Una persona en el color de acento, y un botón | «Verifica que eres humano» y «Soy humano» |
| Error | Una cruz roja, y un botón «Reintentar» | «No se pudo verificar» |
| Caducado | Una exclamación roja, y un botón «Reintentar» | «La verificación ha caducado» (solo con `data-refresh-expired="manual"`) |

El estado correcto es siempre verde y los errores siempre rojos, sea cual sea el acento, para que signifiquen lo mismo en todos los sitios. El elemento del widget lleva el estado actual en `data-state` (`solving`, `success`, `interactive`, `error`, `expired`).

**Color de acento.** Por defecto el widget usa el color del reto de tu zona, el mismo que la [comprobación del navegador](https://developers.openwaf.dev/es/waf/browser-challenge/#settings) (`challenge.color`; una zona sin color propio lleva el cobalto de OpenWAF, `#2456e8`): el borde lo envía con cada reto, así que cambiarlo en **Seguridad → Verificación** cambia todos los widgets de la zona. `data-accent` (o la opción `accent` de `render`) pone otro color en un widget; un valor que no sea `#rrggbb` ni `#rgb` se ignora. El acento colorea el indicador de carga, el icono de interacción, el botón y el anillo de foco del teclado. Si el color no se leería bien, el widget lo oscurece en el tema claro o lo aclara en el oscuro lo justo para cumplir WCAG AA: 3:1 contra el fondo en los anillos y 4,5:1 en el texto del botón, que es blanco o casi negro. Por eso un color muy claro como `#c9ff02` lleva texto oscuro en su botón y un anillo más oscuro en el tema claro.

**Marca.** A la derecha de la caja aparecen el símbolo y el nombre de OpenWAF, que enlazan a `openwaf.dev`, y los enlaces a la política de privacidad y a las condiciones de OpenWAF, en el idioma del widget. Se abren en una pestaña nueva. El tamaño `compact` muestra el símbolo sin el nombre. La marca mantiene sus colores sea cual sea el acento; un widget con tu propia marca (marca blanca) todavía no está disponible.

**Accesibilidad.** La línea de estado es una región viva educada: los lectores de pantalla anuncian cada estado. Los botones tienen etiquetas traducidas, el botón «Soy humano» recibe el foco al aparecer, y botones y enlaces muestran un anillo de foco en el color de acento. El widget no usa estilos en línea, así que basta con `style-src 'self'`: el acento se aplica con propiedades personalizadas de CSS desde el script.

## 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:

```sh
curl -s https://example.com/__waf/challenge/siteverify \
  -d secret="$OWAF_VERIFY_SECRET" \
  -d response="$TOKEN" \
  -d remoteip="203.0.113.7"
```

```json
{"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](https://developers.openwaf.dev/es/waf/siteverify/).

## Ejemplos

**Next.js**

```ts
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**

```php
<?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 cuenta
```
**curl**

```sh
curl -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 humano» 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. Los enlaces de la marca, la privacidad y las condiciones apuntan a `openwaf.dev` y no cargan nada hasta que el visitante los pulsa.
- **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](https://developers.openwaf.dev/es/waf/browser-challenge/#analytics) de tu zona.
