# Verify risk scoring

> Every browser challenge and Verify widget check gets a 0–100 score from what the request already carries; the score picks a level, from a harder proof of work to a block, applied only in enforce mode.

Risk scoring looks at each challenge the edge issues, on the [browser challenge](https://developers.openwaf.dev/waf/browser-challenge/) page, in [under attack mode](https://developers.openwaf.dev/waf/under-attack/) and in the [Verify widget](https://developers.openwaf.dev/waf/verify-widget/), and gives it a score from 0 to 100. The score comes from signals the request already carries: the network it comes from, whether the browser it claims to be adds up, and how the proof of work was solved. No extra fingerprinting and no third parties: see [Data processed](#data).

The score picks a **level**: the proof of work alone, a harder one, a one-click «Verify you are human» step, or a block. In the default **log** mode the levels are only counted, so you can see in the analytics what they would do before applying them.

Risk scoring raises the cost of automation; it does not prove that a visitor is human. Read the [limits](#limits) before you rely on it.

## Levels

The score is the sum of the weights of the [signals](#signals) that fired, kept between 0 and 100. Each zone has three thresholds; the level is the highest one the score reaches.

| Level | Default threshold | Browser challenge page | Verify widget |
|---|---|---|---|
| `pass` | below 30 | The usual proof of work. | The usual proof of work. |
| `harder` | 30 | A longer proof of work: +1 bit of difficulty at the threshold, +1 more every 15 points, at most +3 (each bit doubles the expected work; the total never goes above 24 bits). | The same extra work in the box. |
| `interactive` | 60 | After the proof of work, the one-click step of [under attack mode](https://developers.openwaf.dev/waf/under-attack/#interactive-step). | `managed` widgets ask for the same interaction as for an automated browser (see [modes](https://developers.openwaf.dev/waf/verify-widget/#modes)); `non_interactive` and `invisible` widgets answer `interaction-required`. |
| `block` | 90 | `403` with the zone's [block page](https://developers.openwaf.dev/waf/block-page/) and `OpenWAF-Mitigated: block`. | `403` with `{"error":"blocked"}` (or `{"success":false,"error":"blocked"}` when the solution is checked). |

When each level applies:

- **Browser challenge page.** The score is computed when the page is issued, and `harder`, `interactive` and `block` apply there. When the solution arrives at `/__waf/verify` it is scored again with the solve-time signals; a `block` level then answers `403` with `{"ok":false,"error":"blocked"}` instead of the pass. A solve that only reaches `interactive` is not turned into a click at that point: it marks the network, and its next challenge starts with a higher score.
- **Verify widget.** At `/start` (the box getting its challenge), `harder` adds work and `block` refuses it. When the solution is checked, `interactive` escalates a `managed` widget to the click (unless the visitor already interacted with the page) and `block` refuses it.

A threshold of `0` turns that level off. The non-zero thresholds must increase in the order harder < interactive < block, and at least one must be on.

## Signals

Each signal has a fixed weight. **Issue** signals are evaluated when the challenge is issued; **solve** signals when its solution is checked, together with the issue signals of the same challenge.

| Signal | Weight | Evaluated | What it looks at |
|---|---|---|---|
| `asn_hosting` | 25 | issue | The client's network (ASN) is in OpenWAF's list of hosting and datacenter networks: the MIT-licensed [bad-asn-list](https://github.com/brianhama/bad-asn-list) plus large clouds (AWS, Google Cloud, Azure, OVH, Hetzner, DigitalOcean and others). Relay networks that people browse through, such as iCloud Private Relay and Cloudflare WARP, are never in the list. |
| `ip_feed` | 35 | issue | The client address is in one of the edge's [threat feeds](https://developers.openwaf.dev/waf/threat-feeds/), even when the zone doesn't block feed addresses. |
| `ip_reputation` | 20 | issue | The edge node has seen the address trigger enough WAF detections to give it a bad reputation. |
| `net_velocity` | 15 | issue, solve | 30 or more challenges issued to the client's network (IPv4 /24, IPv6 /64) on the node in about 10 minutes. |
| `net_failures` | 25 | issue, solve | The network failed 5 or more solves in about 10 minutes, and at least half of its attempts. |
| `asn_failures` | 10 | issue, solve | The ASN made 50 or more solve attempts in about 10 minutes and at least 60 % failed. |
| `net_replay` | 30 | issue, solve | In about 10 minutes the network sent a challenge solution twice, or a Verify token was sent twice to [siteverify](https://developers.openwaf.dev/waf/siteverify/). |
| `tls_mismatch` | 30 | issue | The User-Agent claims a recent browser but the connection's TLS ClientHello doesn't come from that browser's TLS stack (see [TLS stacks](#tls)). Not evaluated when the request reaches the edge through another TLS-terminating proxy, nor for User-Agents already counted by `ua_automation`. |
| `headers_missing` | 15 | issue | A browser User-Agent without `Accept-Language`, or a current browser (Chromium 80+, Firefox 90+, Safari 17+) without `Sec-Fetch-Mode`. |
| `client_hints_mismatch` | 20 | issue | The low-entropy client hints every Chromium sends (`Sec-CH-UA`, `Sec-CH-UA-Mobile`, `Sec-CH-UA-Platform`) contradict the User-Agent: Firefox or Safari sending them, Chromium 90+ (not an Android WebView) without them, mobile `?1` with a desktop User-Agent, or a Windows or macOS platform that doesn't match. |
| `ua_automation` | 40 | issue | The User-Agent of a headless browser, automation tool or HTTP library (`HeadlessChrome`, Puppeteer, Playwright, Selenium, `curl`, `python-requests`, `Go-http-client` and similar), or any User-Agent that doesn't start with `Mozilla/`, including an empty one. |
| `webdriver` | 30 | solve | The Verify widget reported `navigator.webdriver` (the browser says it is automated). |
| `solve_too_fast` | 25 | solve, then issue | A proof of work of at least 50,000 hashes solved faster than a browser can: above 8 million hashes per second (4 million for a mobile User-Agent), or in no measurable time after the challenge was issued. Once it fires, the network's next challenges in about 10 minutes carry it too. |
| `solve_timing_repeat` | 20 | solve, then issue | The network's solves keep taking the same time: a solve of at least 200 ms within 3 ms of two of its recent solves. Its next challenges in about 10 minutes carry it too. |
| `cdp_runtime` | 35 | solve | Browser check (Chromium): a DevTools protocol client has enabled the page's JavaScript runtime, as automation frameworks do. Open DevTools also do it. |
| `native_patched` | 25 | solve | Browser check: built-in browser functions or properties were replaced by a script, as "stealth" plugins do. |
| `headless_env` | 30 | solve | Browser check: the window is not that of a desktop browser: a headless Chromium, or a screen emulated to the size of the page. |
| `worker_mismatch` | 25 | solve | Browser check: the proof of work's Web Worker describes a different browser (user agent, platform, languages, processor cores) than the page. |
| `click_dynamics` | 15 | solve, with a click | Browser check on how the verification box was activated: input that people don't produce, such as a press at the exact centre of the box or a key released almost instantly. |
| `client_tampered` | 40 | solve | The answer to the browser checks is missing or doesn't fit that challenge: a solver without a browser, or an answer recorded from another challenge or made up. |
| `pat_valid` | −20 | solve | The device presented a valid [Private Access Token](#pat). It only lowers the score, never below 0, and never lets a challenge pass on its own. **Not enabled yet:** no token issuer is configured, because the issuers that Apple devices accept need an agreement with OpenWAF before they issue for us. |

The timing signals skip challenges that ask for a click, since the visitor's own pace then sets the time. Network and ASN counters are kept on each edge node for about 10 minutes and are not shared between nodes.

The browser checks run in the challenge page and in the widget, each one a yes/no answer that never leaves the page in any other form. Their answer is encoded differently for every challenge, so a recorded answer doesn't fit another one, and the functions they inspect change from one challenge to the next. Their total is capped at the interactive threshold unless a server-side signal also fired: on their own they can ask for a click, never block. The name `cdp_input` is reserved and never fires.

### TLS stacks

The edge computes the [JA4](https://github.com/FoxIO-LLC/ja4) fingerprint of each TLS connection it terminates, from the ClientHello. JA4 changes with every browser release, so `tls_mismatch` doesn't compare exact JA4 strings. It checks that the ClientHello offers TLS 1.3 with `h2` as the first ALPN value, and that it has the stable traits of the browser the User-Agent names:

| User-Agent claims | TLS stack | Traits it must show |
|---|---|---|
| Chromium 100+ (Chrome, Edge, Opera, Brave…) on any system except iOS | BoringSSL | GREASE values and the `application_settings` extension (ALPS) |
| Firefox 100+, except on iOS | NSS | No GREASE values and the `record_size_limit` extension |
| Safari 16+ on macOS, any browser on iOS | Apple's network stack | GREASE values, neither ALPS nor `record_size_limit`, and 17 or more cipher suites |

Older browsers keep a coarser check: TLS 1.3 and `h2` (Chromium 70+, Firefox 70+, Safari 14+), and GREASE for Chromium 70–99.

For example, Chrome 156 sends `t13d1518h2_8daaf6152771_e2d80978ab2e`, `curl` sends `t13d4907h2_…` and Python `requests` sends `t13d1712h1_…`. This catches an automated Chromium whose User-Agent claims Safari or Firefox, and a script that copies a browser's User-Agent. A Firefox-based automation tool with a Firefox User-Agent has Firefox's TLS, so it doesn't fire.

### Private Access Tokens

A Private Access Token ([RFC 9577](https://www.rfc-editor.org/rfc/rfc9577)) lets a device show that it passed its maker's checks (for Apple devices, a genuine device with a signed-in account) without saying who it is. When risk scoring is on and OpenWAF trusts at least one token issuer, the challenge page and the widget ask the edge for a token challenge bound to that challenge and to the site's host. A supporting device answers with a token, which the edge checks offline with the issuer's public key. A valid token adds `pat_valid` when the challenge is solved.

The token carries no identity and can't be linked to the visitor's other visits; the edge checks it and drops it, and the analytics only count `pat_valid` as a signal. Devices without tokens lose nothing: their score is the same as without this feature.

**Status:** not enabled. No issuer is configured, because issuing tokens that Apple devices accept for OpenWAF needs an agreement with such an issuer. Until then the edge never asks for a token and `pat_valid` never fires.

## Mode and thresholds

Risk scoring is the zone's `challenge.risk` object. It applies to every name of the zone; a name can't override it (`hosts.<name>.challenge.risk` is rejected).

| Field | Values | Default | Meaning |
|---|---|---|---|
| `mode` | `off`, `log`, `enforce` | `log` | `off`: challenges aren't scored. `log`: scored and counted in the analytics, **nothing is applied**. `enforce`: each level is applied. |
| `thresholds.harder` | 0–100 | `30` | Score from which the proof of work gets harder. `0` = off. |
| `thresholds.interactive` | 0–100 | `60` | Score from which the visitor must confirm with one click. `0` = off. |
| `thresholds.block` | 0–100 | `90` | Score from which the challenge is refused. `0` = off. |

Fields merge one by one: thresholds you leave out keep their value, and `"risk": null` goes back to the defaults. `GET …/edge` always shows the effective object. Every change is versioned, needs a reason and can be reverted.

**Dashboard**

Domain → **Security → Verification** → **Risk scoring**: the mode (**Off**, **Log only**, **Enforce**) and the three thresholds (**Harder proof of work from**, **Ask for a click from**, **Block from**). The card shows the score bands of your thresholds; **Reset to defaults** goes back to log only, 30, 60 and 90.
**CLI**

```sh
openwaf-cloud ctl zones set example.com challenge.risk.mode=enforce challenge.risk.thresholds.block=95 \
  --reason "Enforce risk scoring"
openwaf-cloud ctl zones set example.com challenge.risk:=null --reason "Risk scoring back to defaults"
```
**API**

```sh
curl -s -X PATCH https://dash.openwaf.dev/v1/orgs/$ORG/zones/$ZONE/edge \
  -H "Authorization: Bearer $OPENWAF_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Change-Reason: Enforce risk scoring" \
  -d '{"challenge": {"risk": {"mode": "enforce", "thresholds": {"block": 95}}}}'
```
Add `?dry_run=1` to see the change without applying it.
**MCP**

`update_settings` with `{"zone": "example.com", "settings": {"challenge": {"risk": {"mode": "enforce", "thresholds": {"block": 95}}}}, "reason": "Enforce risk scoring"}`, first `confirm=false`, then `confirm=true` with `expected_version`.


## From log to enforce

1. **Leave it in log mode** (the default) for a few days of normal traffic, and through an attack if you can.
2. **Read the report** (below): how many challenges would land in each level, the score distribution and the signals that fire most. Look at the share in `interactive` and `block`: those visitors would get a click or a `403`.
3. **Adjust the thresholds** if a signal your real visitors trigger (for example `asn_hosting` for a B2B audience on corporate VPNs or cloud desktops) pushes too many of them up. Raising a threshold, or setting `harder` or `interactive` to `0`, is safer than turning scoring off.
4. **Switch to enforce** and keep watching the report: in enforce mode `escalated` and `blocked` count what was applied.

## Analytics

Domain → **Security → Verification** shows, in the **Risk** section of the activity, the mode and thresholds, the challenges scored, the split by level, the score distribution, how many were asked for a click after solving and how many were blocked, and the signals that fired most. In log mode it says «Log only: nothing is applied» and the counts are what enforce would have done.

The same data is in the `risk` object of `GET /v1/orgs/{org}/zones/{zone}/challenge/analytics?days=7`, of `openwaf-cloud ctl challenge report example.com --days 7` (lines `Riesgo`, `Niveles`, a score histogram and `Señales`) and of the MCP tool `challenge_report`:

```json
"risk": {
  "mode": "log",
  "thresholds": {"harder": 30, "interactive": 60, "block": 90},
  "evaluated": 1230,
  "levels": {"pass": 1100, "harder": 90, "interactive": 32, "block": 8},
  "scores": [900, 120, 80, 50, 30, 20, 15, 7, 0, 5, 3],
  "top_signals": [{"signal": "asn_hosting", "count": 140}, {"signal": "headers_missing", "count": 60}],
  "escalated": 4,
  "blocked": 8
}
```

| Field | Meaning |
|---|---|
| `mode`, `thresholds` | The zone's current setting. |
| `evaluated` | Challenges scored when they were issued. |
| `levels` | Their split by level at issue. |
| `scores` | The same challenges by score: 0–9, 10–19, … 90–99, and 100. |
| `top_signals` | Up to 10 signals and how many times each fired (at issue and at solve). |
| `escalated` | Widget solves asked for a click because of their score (in log mode: would have been). |
| `blocked` | Challenges blocked at issue or at solve (in log mode: would have been). |

Each row of the per-trigger table (`gate`, `rate_rule`, `under_attack`, each widget…) carries its own `risk_levels`. The analytics keep aggregates for 30 days.

## What is never scored

Risk scoring only scores challenges the edge was going to issue anyway; it never challenges a request by itself. Requests that skip the challenge skip scoring too:

- addresses and networks with an [IP or ASN `allow` rule](https://developers.openwaf.dev/waf/ip-and-country-rules/);
- verified crawlers ([Bot verification](https://developers.openwaf.dev/ai-crawl-control/verification/));
- the `exempt_paths` of [under attack mode](https://developers.openwaf.dev/waf/under-attack/#exemptions), and the edge's own `/__waf/` paths;
- requests that aren't browser navigations: they get the usual [non-navigation answer](https://developers.openwaf.dev/waf/browser-challenge/#non-navigations), not a challenge;
- visitors that already hold a valid pass.

Under attack mode is scored like any other challenge. Its own click (`interactive`, on by default) doesn't depend on the score; in enforce mode a high score can still make the work harder or block.

## Data processed

What the risk engine reads, all of it already in the request or the solve:

- **The client address**, to look up its ASN, the threat feeds and the reputation, in the edge node's memory.
- **The JA4 fingerprint of the TLS ClientHello** of the connection (see [TLS stacks](#tls)). The edge reads the ClientHello once to compute it and drops the raw bytes.
- **Low-entropy headers** every browser sends: `User-Agent`, `Accept-Language`, `Sec-Fetch-Mode`, `Sec-CH-UA`, `Sec-CH-UA-Mobile`, `Sec-CH-UA-Platform`.
- **A Private Access Token**, only if the device offers one and [the feature](#pat) is enabled (it isn't yet). It is checked and dropped.
- **`navigator.webdriver`**, already sent by the Verify widget.
- **The browser checks' answer**: one yes/no per check, encoded for the challenge. The page compares, without sending them: whether the JavaScript runtime is being serialized for a debugger, whether a few built-in functions are still the browser's own, the window and screen sizes, the Web Worker's user agent, platform, languages and processor cores against the page's, and, for the verification box, where in the box the press landed and how long the key was held.
- **The proof of work's timing**: when the challenge was issued, when the solution arrived and the work it implies.

What it keeps: per-network counters (keyed by a hash of the IPv4 /24 or IPv6 /64) and per-ASN counters, for about 10 minutes in the edge node's memory. The analytics receive only levels, score buckets and signal names, per site and per minute, with no IP address; they are kept for 30 days. The security events of a request store its JA4 string in `tls_fingerprint` (empty when another proxy terminated TLS, or on plain HTTP), never the raw ClientHello.

What it never collects: canvas, WebGL, audio or font fingerprints, plugins, or high-entropy client hints. The edge never sends `Accept-CH` to ask for more hints.

Everything Verify processes, for how long and where, the roles under the DPA and a paragraph for your privacy policy: [Data processed by OpenWAF Verify](https://openwaf.dev/legal/verify-data/) (draft pending legal review).

## Limits

- **Cost, not proof.** Risk scoring makes automation slower and more expensive. Automation that drives a real browser through the operating system or the DevTools protocol, from residential or mobile addresses, sends the same headers and TLS as a person and can still pass. The browser checks catch the default behaviour of common frameworks; a framework tuned to imitate a person's input, or input injected by the operating system, passes them.
- **Browser checks are hints.** A visitor with DevTools open, or using voice control or switch access that presses the exact centre of the box, can fire one; that is why they never block on their own.
- **TLS check.** `tls_mismatch` uses the JA4 fingerprint and stable traits of each browser's TLS stack, not a list of exact fingerprints, so a tool that reproduces a browser's TLS exactly passes it. It is skipped when another TLS-terminating proxy sits in front of the edge, since the handshake is then the proxy's.
- **Hosting or not.** The ASN list only tells hosting and datacenter networks from the rest; there is no mobile or residential classification.
- **Per-node memory.** Network and ASN counters live on each edge node for about 10 minutes, so traffic spread over several nodes builds them up more slowly.
- **No click after the page's solve.** On the challenge page, `interactive` applies when the page is issued; a solve-time score can only block or mark the network for its next challenge.
- **Siteverify unchanged.** [Siteverify](https://developers.openwaf.dev/waf/siteverify/) still answers `metadata.risk` as `low`, or `high` when interaction was required; it doesn't carry the score or the signals.
- **Log mode applies nothing.** In log mode the levels only appear in the analytics.
