# OpenWAF Verify widget

> Protect a form or an API call with a proof-of-work widget served from your own host, then check its token on your server with siteverify.

OpenWAF Verify is a widget you embed in a form. The visitor's browser solves a short proof of work and gets a single-use token; your server sends that token to [siteverify](https://developers.openwaf.dev/waf/siteverify/) before it accepts the form. It uses the same engine as the [browser challenge](https://developers.openwaf.dev/waf/browser-challenge/).

Everything is **first party**: the script, its worker and its endpoints are served by the OpenWAF edge under `/__waf/` on your own proxied name. There is no third-party iframe and no third-party cookie, and the widget works with `Content-Security-Policy: default-src 'self'` (no inline script or style, no `eval`, no `blob:`).

**Live demo:** [developers.openwaf.dev/demo/verify/](https://developers.openwaf.dev/demo/verify/) runs this widget on this very site, behind the OpenWAF edge, under `default-src 'self'`.

The name that renders the widget must be proxied by OpenWAF.

## How it works

1. Create a widget for your zone. You get a public **site key** (`owk_…`) and a **secret** (`ows_…`), shown once.
2. Add the script and a container with the site key to your page.
3. The widget asks the edge for a challenge, solves it in a Web Worker and puts the token in a hidden `owaf-verify-response` field of the form.
4. Your server posts the token and the secret to siteverify and accepts the form only when the answer has `"success": true`.


## Create a widget

A widget has a name (1–64 characters), a [mode](#modes) and 1–10 hostnames of the zone (the apex or names below it, no wildcards) where it may be rendered. A zone can have up to 20 widgets.

**Dashboard**

Domain → **Security → Verification → Widgets → Add widget**. The secret appears once in a dialog with a copy button: store it before you close it.
**CLI**

```sh
openwaf-cloud ctl challenge widget-add example.com --name "Signup form" --mode managed \
  --hostname example.com --hostname www.example.com --reason "Protect signup"
```
The secret is printed once to standard output.
**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": "Signup form", "mode": "managed", "hostnames": ["example.com", "www.example.com"], "reason": "Protect signup"}'
```
The answer is `201` with `{"widget": {…}, "secret": "ows_…"}`. Add `?dry_run=1` to validate without creating anything.
**MCP**

`challenge_widgets` with `{"zone": "example.com", "action": "create", "name": "Signup form", "mode": "managed", "hostnames": ["example.com"], "reason": "Protect signup"}`, first `confirm=false`, then `confirm=true`. The confirmed result carries the secret once.


> **Caution: The secret is shown once**
>
> OpenWAF stores only a hash of the secret. If you lose it, [rotate it](#rotate). Keep it on your server only; never put it in the page.


## Add it to your page

```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_YOUR_SITE_KEY" data-action="signup"></div>
  <button type="submit">Sign up</button>
</form>
```

The script finds every `.owaf-verify` element and renders a widget in it. When the browser has solved the challenge, the container holds `<input type="hidden" name="owaf-verify-response" value="v1.…">`, which the form submits with the rest of the fields.

| Attribute | Values | Meaning |
|---|---|---|
| `data-sitekey` | `owk_…` | Required. Site key of a widget whose hostnames include this name. |
| `data-action` | 0–32 of `A-Z a-z 0-9 _ -` | Label returned by siteverify, so your server can check the token was made for this form. |
| `data-cdata` | up to 255 printable ASCII characters | Your own data, returned by siteverify. |
| `data-callback` | function name | Called with the token on success. |
| `data-error-callback` | function name | Called with an error code when the widget fails. |
| `data-expired-callback` | function name | Called when the token expires before it is used. |
| `data-theme` | `auto` (default), `light`, `dark` | Colours of the box. |
| `data-language` | `auto` (default), `en`, `es` | Language of the box; `auto` follows the browser. |
| `data-accent` | `#rrggbb` or `#rgb` | Accent colour of this widget. Default: your zone's challenge colour. See [Appearance](#appearance). |
| `data-size` | `normal` (default), `compact` | `normal` is 300×65; `compact` is 150×140. |
| `data-response-field-name` | field name | Name of the hidden field. Default `owaf-verify-response`. |
| `data-refresh-expired` | `auto` (default), `manual` | `auto` solves a new challenge before the token expires and announces it; `manual` calls the expired callback instead. |

### JavaScript API

Load the script with `?render=explicit` to render widgets yourself, and with `onload=<function>` to be called when it is ready:

```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_YOUR_SITE_KEY",
    action: "login",
    callback: (token) => console.log("ready"),
  });
}
```

| Method | Effect |
|---|---|
| `owafVerify.render(elementOrSelector, options)` | Renders a widget and returns its id. Options use the attribute names without `data-` (`sitekey`, `action`, `cdata`, `callback`…). |
| `owafVerify.execute(id)` | Starts the challenge. Required for `invisible` widgets. |
| `owafVerify.reset(id)` | Discards the token and starts again. Call it after a failed submission: a token is valid once. |
| `owafVerify.getResponse(id)` | Returns the current token, or an empty string. |
| `owafVerify.isExpired(id)` | Whether the current token has expired. |
| `owafVerify.remove(id)` | Removes the widget. |

## Modes

| Mode | What the visitor sees | Interaction |
|---|---|---|
| `managed` | A box with a status line | Only when the browser reports that it is automated (`navigator.webdriver`): the box shows «Confirm you're human» with an «I'm human» button labelled with your host name, and a harder challenge. |
| `non_interactive` | A box with a status line | Never. An automated browser gets the error `interaction-required`. |
| `invisible` | Nothing | Never. Call `execute(id)`, for example when the form is submitted. An automated browser gets `interaction-required`. |

In this release the only signal that asks for interaction is `navigator.webdriver`. Server-side risk signals will come in a later release.

## Appearance

The box follows `data-theme` and `data-language`, and shows one icon per state:

| State | Icon | Text (English) |
|---|---|---|
| Solving | A ring that spins in the accent colour (a still ring if the visitor asks for reduced motion) | «Verifying…», then «Still verifying…» after 4 seconds |
| Success | A green check, drawn in a short animation (no animation with reduced motion) | «Verified» |
| Interactive | A person in the accent colour, and a button | «Confirm you're human» and «I'm human» |
| Error | A red cross, and a «Try again» button | «Verification failed» |
| Expired | A red exclamation mark, and a «Try again» button | «Verification expired» (only with `data-refresh-expired="manual"`) |

Success is always green and errors always red, whatever the accent, so they mean the same on every site. The widget element carries the current state in `data-state` (`solving`, `success`, `interactive`, `error`, `expired`).

**Accent colour.** By default the widget uses your zone's challenge colour, the same as the [browser challenge](https://developers.openwaf.dev/waf/browser-challenge/#settings) (`challenge.color`; a zone without one gets the OpenWAF cobalt `#2456e8`): the edge sends it with each challenge, so changing it in **Security → Verification** changes every widget of the zone. `data-accent` (or the `accent` option of `render`) sets another colour for one widget; a value that is not `#rrggbb` or `#rgb` is ignored. The accent colours the spinner, the interactive icon, the button and the keyboard focus ring. If the colour would not be readable, the widget darkens it on the light theme or lightens it on the dark theme just enough to reach WCAG AA: 3:1 against the background for the rings, and 4.5:1 for the button text, which is white or near-black. A very light colour such as `#c9ff02` therefore keeps a dark text on its button and gets a darker ring on the light theme.

**Brand.** The right side of the box shows the OpenWAF mark and name, which link to `openwaf.dev`, and links to the OpenWAF privacy policy and terms, in the widget's language. They open in a new tab. The `compact` size shows the mark without the name. The brand keeps its own colours whatever the accent; a widget with your own brand instead (white label) is not available yet.

**Accessibility.** The status line is a polite live region: screen readers announce each state. The buttons have localized labels, the «I'm human» button gets the focus when it appears, and buttons and links show a focus ring in the accent colour. The widget uses no inline style, so `style-src 'self'` is enough: the accent is applied through CSS custom properties from the script.

## Tokens

- A token starts with `v1.`, is at most 2048 characters long and is valid for **300 seconds**.
- It is **single use**: the first siteverify call spends it on every edge node and on the control plane; a second one gets `timeout-or-duplicate`.
- It is bound to the visitor's network (IPv4 /24, IPv6 /64) and carries the host name, the action and the cdata.

## Verify the token on your server

Send the token to siteverify from your server, never from the browser. You can call it on your own host, through the edge, or on the control plane:

```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"}}
```

Check `success`, then `hostname` and `action`. Fields, error codes, limits and idempotency are in the [siteverify reference](https://developers.openwaf.dev/waf/siteverify/).

## Examples

**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 });
  }
  // create the account
  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('Verification failed');
}
// create the account
```
**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"}'
```


Behind the OpenWAF edge, the client address your origin sees in `REMOTE_ADDR` is the edge's; take the visitor's address from the header your origin trusts, or leave `remoteip` out.

## Test keys

These keys work on every host with OpenWAF Verify and on both siteverify endpoints. They never spend a counter and never produce a real token.

| Key | Behaviour |
|---|---|
| `owk_test_pass` | Site key: always succeeds with the token `v1.TEST.TOKEN`. |
| `owk_test_fail` | Site key: always fails with the error `test-fail`. |
| `owk_test_interactive` | Site key: always shows the «I'm human» step, then gives `v1.TEST.TOKEN`. |
| `ows_test_pass` | Secret: any non-empty response succeeds, with `metadata.result_with_testing_key: true`. |
| `ows_test_fail` | Secret: always `invalid-input-response`. |
| `ows_test_spent` | Secret: always `timeout-or-duplicate`. |

## Manage widgets

| Task | Dashboard | CLI | API | MCP |
|---|---|---|---|---|
| List | **Security → Verification → Widgets** | `challenge widgets <zone>` | `GET …/zones/{zone}/challenge/widgets` | `challenge_widgets` `action: list` |
| Change name, mode or hostnames | **Edit** | `challenge widget-set <zone> <id> [--name] [--mode] [--hostname…] --reason` | `PATCH …/challenge/widgets/{widget}` | `action: update` |
| Rotate the secret | **Rotate secret** | `challenge rotate-secret <zone> <id> --reason` | `POST …/challenge/widgets/{widget}/rotate-secret` | `action: rotate_secret` |
| Delete | **Delete** | `challenge widget-rm <zone> <id> --reason` | `DELETE …/challenge/widgets/{widget}` | `action: delete` |

Reading needs a `readonly` token; changes need `tech` and a reason. Widgets are not part of the zone's versioned configuration: changes are recorded in the organization's audit log (`challenge.widget.create`, `update`, `delete`, `rotate_secret`), never with the secret, and reverting a zone version doesn't touch them. Every write accepts `?dry_run=1`.

### Rotate the secret

Rotating returns a new secret once. The previous secret keeps working for **24 hours**, so you can deploy the new one without failed verifications. After a second rotation within those 24 hours, only the two newest secrets are valid.

## Migrate from Cloudflare Turnstile

The siteverify fields and most error codes have the same names, so a migration is mostly renaming:

| 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.…` (same methods) |
| `https://challenges.cloudflare.com/turnstile/v0/siteverify` | `https://<your host>/__waf/challenge/siteverify` or `https://dash.openwaf.dev/v1/challenge/siteverify` |
| `secret`, `response`, `remoteip`, `idempotency_key` | the same |

Remove `challenges.cloudflare.com` from your Content Security Policy afterwards: nothing is loaded from another origin.

## Data processed

The widget is built to check that a browser did the work, not to identify the person:

- **Network prefix.** The edge uses the visitor's IPv4 /24 or IPv6 /64 to bind the challenge and the token. It is kept in memory and only a hash of it goes into the token.
- **`navigator.webdriver`.** Whether the browser reports that it is automated.
- **Interaction.** Whether there was interaction with the page before the check: only yes or no, never movements or keys.
- **No fingerprinting.** No canvas, WebGL, audio, fonts, plugins or high-entropy client hints, and no persistent identifier.
- **No cookies.** The widget sets no cookie and makes no request to another domain. The brand, privacy and terms links point to `openwaf.dev` and load nothing until the visitor clicks them.
- **Where.** Challenges and tokens are issued on OpenWAF edge nodes in the EU (Germany and Spain).

Siteverify answers never include these signals. Aggregated counts (challenges issued, solved, failed; siteverify outcomes) appear in your zone's [analytics](https://developers.openwaf.dev/waf/browser-challenge/#analytics).
