Risk scoring looks at each challenge the edge issues, on the browser challenge page, in under attack mode and in the 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.
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 before you rely on it.
Levels
The score is the sum of the weights of the 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. | managed widgets ask for the same interaction as for an automated browser (see modes); non_interactive and invisible widgets answer interaction-required. |
block | 90 | 403 with the zone's 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,interactiveandblockapply there. When the solution arrives at/__waf/verifyit is scored again with the solve-time signals; ablocklevel then answers403with{"ok":false,"error":"blocked"}instead of the pass. A solve that only reachesinteractiveis 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),harderadds work andblockrefuses it. When the solution is checked,interactiveescalates amanagedwidget to the click (unless the visitor already interacted with the page) andblockrefuses 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 (external link) 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, 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. |
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). 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. 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 (external link) 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 (external link)) 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.
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.
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"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.
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
- Leave it in log mode (the default) for a few days of normal traffic, and through an attack if you can.
- 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
interactiveandblock: those visitors would get a click or a403. - Adjust the thresholds if a signal your real visitors trigger (for example
asn_hostingfor a B2B audience on corporate VPNs or cloud desktops) pushes too many of them up. Raising a threshold, or settingharderorinteractiveto0, is safer than turning scoring off. - Switch to enforce and keep watching the report: in enforce mode
escalatedandblockedcount 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:
"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
allowrule; - verified crawlers (Bot verification);
- the
exempt_pathsof under attack mode, and the edge's own/__waf/paths; - requests that aren't browser navigations: they get the usual non-navigation answer, 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). 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 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 (external link) (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_mismatchuses 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,
interactiveapplies when the page is issued; a solve-time score can only block or mark the network for its next challenge. - Siteverify unchanged. Siteverify still answers
metadata.riskaslow, orhighwhen 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.