Under attack mode puts the browser challenge in front of the whole zone. While it is on, every browser navigation without a valid pass gets the challenge page, whatever the gate says, and every other request without a pass gets a short 403. Clients that can't run JavaScript stop reaching your origin; real visitors wait a moment, click «I am human» once and then browse normally.
It is off by default, it is set for the whole zone (optionally for only some of its names) and it can switch itself off at a time you choose.
When to use it
Turn it on for the duration of an attack that your usual rules can't keep up with:
- an application-layer (L7) flood: many addresses requesting your pages, too spread out for rate limiting or IP and ASN rules to stop;
- a scraping wave from rotating proxies or residential addresses.
Set an end time when you turn it on, so it doesn't stay on after the attack.
What visitors see
A browser navigation (a GET or HEAD whose Accept includes text/html, with Sec-Fetch-Mode absent or navigate) without a valid pass gets the challenge page:
- It answers
503withRetry-After: 30,Cache-Control: no-store,X-Robots-Tag: noindexandOpenWAF-Mitigated: challenge. AHEADgets only the headers. - It says «Checking your browser before accessing www.example.com» (in Spanish, «Comprobando tu navegador antes de acceder a www.example.com»), with the zone's brand, accent colour and the visitor's language.
- The browser solves the proof of work while the page says it is checking. By default it is one bit harder than the name's normal challenge, which doubles the work: usually under a second or two, longer on slow phones.
- With the interactive step (on by default), the page then asks «Verify you are human» and shows an «I am human» button («Soy humano» in Spanish). The visitor clicks it, taps it or presses Enter or Space on it.
- The page shows «Verifying…» and, when the edge accepts it, «Done. Loading the page…»: the edge sets the
__waf_passcookie and the page reloads. While the pass is valid, the visitor browses normally. If the check fails, the page offers a «Try again» button.
Unlike the gate, under attack mode also shows the page on api.* names. Passes earned before you turned it on stay valid until they expire.
Interactive step
On a fast connection the proof of work is done before the visitor notices, so a page that passed by itself would look like no protection at all, and a script that can compute SHA-256 would get through just the same. While under attack, the page therefore asks by default for one click or key press on a button (interactive: true). It is friction for scripts, not proof that a person is there:
- The button appears once the proof of work is solved, gets the keyboard focus, and its states are announced to screen readers. It uses the zone's brand and accent colour, and follows the light or dark preference, like the Verify widget's interactive state.
- Only a trusted browser input event on the button counts (a click, tap, or Enter or Space): a click synthesised by a script on the page, such as
element.click(), is not trusted and fails. The edge also rejects a click that comes too soon after the button appeared or after the challenge was issued, which stops instant replays:/__waf/verifyanswers403withinteraction-requiredand the page offers «Try again». These attempts count as failed challenges. - Browsers that report they are automated (
navigator.webdriver) get the same button as everybody else: there is no extra fingerprinting. - Passes, their lifetime and their scope don't change; only the way they are earned does.
Effect on automation. Scrapers and flood tools that only fetch your pages, or that run them in a headless browser and solve the proof of work without real input, no longer get a pass. Automation that injects input at the browser or operating-system level (for example Playwright or Puppeteer driving the DevTools protocol) produces trusted events and can pass, as can a person paid to click. The step raises the cost of each new pass, one network at a time; it doesn't prove human intent. Requests that never see the page (APIs, webhooks, exempt paths) are not affected either way.
Set interactive to false to go back to the proof of work alone, so the page passes by itself, for example while you test the mode on a staging name or if your visitors use kiosks where nobody can click. See Turn it on and off.
APIs, mobile apps and webhooks
Any other request without a pass gets 403, never the HTML page: API calls, form posts, fetch and XHR, mobile apps, webhooks, server-to-server calls. The body is JSON when Accept includes json or the request is a fetch (Sec-Fetch-Mode cors, same-origin or no-cors):
HTTP/1.1 403 Forbidden
Content-Type: application/json
Cache-Control: no-store
OpenWAF-Mitigated: challenge
X-Robots-Tag: noindex
{"error":"challenge_required","message":"www.example.com is under attack protection. Open it in a browser to continue."}Otherwise it is one line of plain text, and a HEAD gets no body. Your clients can recognise it by the OpenWAF-Mitigated: challenge header.
- Your own web front end. Calls made by pages that already passed the challenge carry the cookie and go through. If
www.example.comcallsapi.example.com, setpass_scope: zoneso one pass covers both names, and send the calls with credentials. - Mobile apps, partners and server-to-server clients can't hold a pass. Leave their names out with
hosts, exempt their paths, or allow their addresses with an IP or ASNallowrule. - Webhooks from payment providers and other services: exempt their paths.
GET /__waf/status answers "required": true on a covered name, so a single-page application can reload once to get a pass, as with the gate.
What passes without a challenge
These requests skip under attack mode (the rest of the edge, such as the WAF and rate limits, still applies to them):
| Request | Notes |
|---|---|
| The edge's own paths | /__waf/… (challenge, status, access gate) and /.well-known/acme-challenge/…. |
Paths in exempt_paths | Prefixes matched by whole path segments, ignoring case: /webhooks/stripe matches /webhooks/stripe/events, not /webhooks/stripe2. |
A valid __waf_pass | A pass from the challenge page, earned before or while the mode is on. |
IP allow rules | The zone's and the name's. Country allow rules don't exempt. |
ASN allow rules | Every address of the allowed network. |
WAF exceptions with rule_id 0 | An exception for every rule that matches the host and path. |
| Verified crawlers | Search engines and other crawlers verified by their published IP ranges, forward-confirmed reverse DNS or Web Bot Auth, whatever the AI Crawl Control mode, so search engines keep indexing your site. The AI Crawl Control policy still applies to them. A request that only claims to be a crawler in its User-Agent is challenged. |
Exempt webhooks
Webhooks, payment callbacks and health checks can't solve a challenge. Add their path prefixes to exempt_paths (up to 50; a new list replaces the whole list) when you turn the mode on, or later:
{"challenge": {"under_attack": {"exempt_paths": ["/webhooks/stripe", "/api/payments/callback", "/healthz"]}}}Exempt paths still go through the WAF, rate limits and your rules. If a provider publishes the addresses it sends from, an IP allow rule for them works too.
Only some names
hosts limits the mode to some names of the zone (up to 50, the apex or names under it); an empty list covers every proxied name. For example, protect www.example.com and shop.example.com and leave api.example.com, used by your mobile app, as it is:
{"challenge": {"under_attack": {"enabled": true, "hosts": ["www.example.com", "shop.example.com"]}}}The mode is a zone setting: it covers names that have their own challenge settings too, and it can't be set per name with hosts.<name>.challenge.
Difficulty and pass lifetime
difficulty:0(the default) uses the name's challenge difficulty plus one bit, up to 24, so attackers pay twice the usual work. Set 10–24 for a fixed value; each extra bit doubles the work, for your visitors too.pass_ttl_minutes:0(the default) keeps the name's pass lifetime. Set 5–10080 to give shorter passes, for example60, so that a solved challenge can't be reused for long. It applies to passes earned while the mode is on.
Automatic end
until is the time (RFC 3339, UTC) at which the mode switches itself off: more than one minute ahead and at most 30 days ahead. null keeps it on until you turn it off.
When until passes, within about 30 seconds the control plane turns the mode off as a normal change: it creates a new zone version with the reason «Under attack mode expired (until 2026-10-28T18:00:00Z)», which you see in the zone's history and the audit log, and the edge nodes apply it like any other version. Turning the mode off by hand clears until.
Turn it on and off
Every change is simulated first, needs a reason, creates a new version and can be reverted.
Domain → overview, or Security → Verification → Under attack mode. Choose how long (1 h, 6 h, 24 h or until you turn it off) and, under the advanced options, the names, exempt paths, difficulty and pass lifetime. Ask for a click (I am human) is on by default; switch it off for the proof of work alone. Review the change and apply it with a reason. While it is on, every page of the zone shows a banner with the time left and a button to turn it off.
openwaf-cloud ctl zones under-attack example.com on --for 6h --exempt /webhooks/stripe --reason "HTTP flood"
openwaf-cloud ctl zones under-attack example.com status
openwaf-cloud ctl zones under-attack example.com off --reason "Attack over"on and off show the change and ask for confirmation (--yes skips it). on also takes --hosts www.example.com,shop.example.com, --difficulty 18, --pass-ttl 60 and --interactive=false (proof of work without the «I am human» step; without the option the zone keeps its current choice); without --for it stays on until you turn it off. status shows whether the click is asked. zones set example.com challenge.under_attack.interactive=false … works too. See zones under-attack.
curl -s -X PATCH "https://dash.openwaf.dev/v1/orgs/$ORG/zones/$ZONE/edge?dry_run=1" \
-H "Authorization: Bearer $OPENWAF_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Change-Reason: HTTP flood" \
-d '{"challenge": {"under_attack": {"enabled": true, "until": "2026-10-28T18:00:00Z", "exempt_paths": ["/webhooks/stripe"]}}}'?dry_run=1 shows the change without applying it; send the same request without it to apply. Add "interactive": false inside under_attack for the proof of work without the «I am human» step. To turn the mode off, send {"challenge": {"under_attack": {"enabled": false}}}. In GET …/edge, challenge.under_attack is absent while the mode has never been set or is back to its defaults; when present, it shows interactive as true or false.
under_attack with {"zone": "example.com", "enabled": true, "duration_minutes": 360, "exempt_paths": ["/webhooks/stripe"], "reason": "HTTP flood"}, first confirm=false, then confirm=true with expected_version. duration_minutes is 1–43200; without it the mode stays on until you turn it off. It also takes hosts, difficulty, pass_ttl_minutes and interactive (false for the proof of work alone). To turn it off: {"zone": "example.com", "enabled": false, "reason": "Attack over"}. update_settings accepts challenge.under_attack too.
Fields
challenge.under_attack in the zone's edge settings:
| Field | Values | Default | Meaning |
|---|---|---|---|
enabled | true, false | false | Turns the mode on. false clears until. |
until | RFC 3339 UTC time, or null | null | When the mode switches itself off: 1 minute to 30 days ahead. Only with enabled: true; null means until you turn it off. |
hosts | 0–50 names of the zone | [] | Names it covers; empty means every proxied name. |
exempt_paths | 0–50 path prefixes | [] | Paths never challenged, such as webhooks. |
difficulty | 0, or 10–24 | 0 | 0: the name's challenge difficulty plus one, up to 24. |
pass_ttl_minutes | 0, or 5–10080 | 0 | Lifetime of passes earned while the mode is on. 0: the name's pass_ttl_minutes. |
interactive | true, false | true | Ask for the «I am human» click after the proof of work. false: the page passes by itself. null goes back to true; turning the mode on without it keeps the current value. |
Fields merge one by one like the other settings; hosts and exempt_paths replace the whole list, a null field goes back to its default and "under_attack": null turns the mode off and resets every field. hosts.<name>.challenge.under_attack is rejected with 422: use challenge.under_attack.hosts.
Watch the attack
Domain → Security → Verification shows whether the mode is on, until when and whether it asks for a click, and the challenges it issued, solved and failed under the under_attack trigger, next to the other challenge analytics. Solves with the «I am human» click are also counted as interactive. A request that falsely claims to be a crawler is counted under crawler. The same report is openwaf-cloud ctl challenge report example.com, GET /v1/orgs/{org}/zones/{zone}/challenge/analytics and the MCP tool challenge_report.
A high solve rate means mostly real visitors; many issued challenges that are never solved, or many failures with the interactive step, are the attack being held at the edge.