OpenWAF Verify widgetNew

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.

On this page

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

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

Add it to your page

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

AttributeValuesMeaning
data-sitekeyowk_…Required. Site key of a widget whose hostnames include this name.
data-action0–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-cdataup to 255 printable ASCII charactersYour own data, returned by siteverify.
data-callbackfunction nameCalled with the token on success.
data-error-callbackfunction nameCalled with an error code when the widget fails.
data-expired-callbackfunction nameCalled when the token expires before it is used.
data-themeauto (default), light, darkColours of the box.
data-languageauto (default), en, esLanguage of the box; auto follows the browser.
data-response-field-namefield nameName of the hidden field. Default owaf-verify-response.
data-refresh-expiredauto (default), manualauto 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>
app.js
function onVerifyReady() {
  const id = window.owafVerify.render("#verify", {
    sitekey: "owk_YOUR_SITE_KEY",
    action: "login",
    callback: (token) => console.log("ready"),
  });
}
MethodEffect
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

ModeWhat the visitor seesInteraction
managedA 300×65 box with a status lineOnly 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_interactiveA 300×65 box with a status lineNever. An automated browser gets the error interaction-required.
invisibleNothingNever. 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

app/api/signup/route.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 });
}

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.

KeyBehaviour
owk_test_passSite key: always succeeds with the token v1.TEST.TOKEN.
owk_test_failSite key: always fails with the error test-fail.
owk_test_interactiveSite key: always shows the «I'm a person» step, then gives v1.TEST.TOKEN.
ows_test_passSecret: any non-empty response succeeds, with metadata.result_with_testing_key: true.
ows_test_failSecret: always invalid-input-response.
ows_test_spentSecret: always timeout-or-duplicate.

Manage widgets

TaskDashboardCLIAPIMCP
ListSecurity → Verification → Widgetschallenge widgets <zone>GET …/zones/{zone}/challenge/widgetschallenge_widgets action: list
Change name, mode or hostnamesEditchallenge widget-set <zone> <id> [--name] [--mode] [--hostname…] --reasonPATCH …/challenge/widgets/{widget}action: update
Rotate the secretRotate secretchallenge rotate-secret <zone> <id> --reasonPOST …/challenge/widgets/{widget}/rotate-secretaction: rotate_secret
DeleteDeletechallenge widget-rm <zone> <id> --reasonDELETE …/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:

TurnstileOpenWAF Verify
https://challenges.cloudflare.com/turnstile/v0/api.js/__waf/challenge/v1/widget.js
class="cf-turnstile"class="owaf-verify"
cf-turnstile-responseowaf-verify-response
window.turnstile.render/execute/reset/getResponse/remove/isExpiredwindow.owafVerify.… (same methods)
https://challenges.cloudflare.com/turnstile/v0/siteverifyhttps://<your host>/__waf/challenge/siteverify or https://dash.openwaf.dev/v1/challenge/siteverify
secret, response, remoteip, idempotency_keythe 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.

Last updated:

↑↓ to navigate↵ to openesc to close