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 before it accepts the form. It uses the same engine as the 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/ 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
- Create a widget for your zone. You get a public site key (
owk_…) and a secret (ows_…), shown once. - Add the script and a container with the site key to your page.
- The widget asks the edge for a challenge, solves it in a Web Worker and puts the token in a hidden
owaf-verify-responsefield of the form. - 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 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.
Domain → Security → Verification → Widgets → Add widget. The secret appears once in a dialog with a copy button: store it before you close it.
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.
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.
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.
Add it to your page
<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-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:
<script src="/__waf/challenge/v1/widget.js?render=explicit&onload=onVerifyReady" async defer></script>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 300×65 box with a status line | Only when the browser reports that it is automated (navigator.webdriver): the box shows an «I'm a person» button labelled with your host name, and a harder challenge. |
non_interactive | A 300×65 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. The status line is a live region that screen readers announce, and the box can be reached with the keyboard.
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:
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"}}Check success, then hostname and action. Fields, error codes, limits and idempotency are in the siteverify reference.
Examples
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
$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 accountcurl -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 a person» 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.
- 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.