# Under attack mode

> Put every page of a zone behind the browser challenge during an application-layer attack, and let it switch itself off when the attack is over.

Under attack mode puts the [browser challenge](https://developers.openwaf.dev/waf/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](https://developers.openwaf.dev/waf/browser-challenge/#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, tick a «Verify you are human» checkbox 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](https://developers.openwaf.dev/waf/rate-limiting/) or [IP and ASN rules](https://developers.openwaf.dev/waf/ip-and-country-rules/) to stop;
- a **scraping wave** from rotating proxies or residential addresses.

Set an [end time](#until) when you turn it on, so it doesn't stay on after the attack.

> **Caution: Not a permanent setting**
>
> Every first visit waits for the challenge, and clients that can't run it (API clients, mobile apps, webhooks, feed readers, uptime monitors) get `403` unless you [exempt](#exemptions) them. For lasting protection use targeted tools instead: the [gate](https://developers.openwaf.dev/waf/browser-challenge/#gate) for sign-up and contact forms, [rate limiting rules](https://developers.openwaf.dev/waf/rate-limiting/#per-path-rules) with `action: challenge` for abused paths, [IP and ASN rules](https://developers.openwaf.dev/waf/ip-and-country-rules/) for known networks and [AI Crawl Control](https://developers.openwaf.dev/ai-crawl-control/) for bots.


## 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](https://developers.openwaf.dev/waf/browser-challenge/#what-the-visitor-sees):

1. It answers `503` with `Retry-After: 30`, `Cache-Control: no-store`, `X-Robots-Tag: noindex` and `OpenWAF-Mitigated: challenge`. A `HEAD` gets only the headers.
2. 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.
3. 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.
4. With the [interactive step](#interactive-step) (on by default), the page then shows the site's name as its heading, the line «Verify you are human by completing the action below.» and a verification box with a «Verify you are human» checkbox («Verifica que eres humano» in Spanish). The visitor ticks it with a click or a tap, or with Space when it has the focus.
5. The checkbox turns into a spinner with «Verifying…», then a green check with «Success», and the page reloads: the edge has set the `__waf_pass` cookie. While the pass is valid, the visitor browses normally. If the check fails, the box shows a red cross and 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 action in a verification box (`interactive: true`). It is friction for scripts, not proof that a person is there.

Once the proof of work is solved, the page shows:

- the site's name as the heading, and «Verify you are human by completing the action below.» («Verifica que eres humano marcando la casilla.» in Spanish);
- the **verification box**, the same one as the [Verify widget's](https://developers.openwaf.dev/waf/verify-widget/#appearance) interactive state: a «Verify you are human» checkbox on the left and the zone's brand on the right, in the zone's accent colour, light or dark as the visitor prefers;
- «www.example.com needs to review the security of your connection before proceeding.»;
- a small footer, «Protected by OpenWAF | ID: 3f9c2a7b1e604d85» (with a white-label brand, your agency's wording instead of OpenWAF's).

The box goes through these states:

| State | What the visitor sees |
|---|---|
| Idle | An empty checkbox with its label. It is a real checkbox: it takes the keyboard focus, shows a focus ring in the accent colour, and its label can be clicked too. |
| Verifying | After a click, a tap or Space, the checkbox turns into a spinner (still if the visitor asks for reduced motion) with «Verifying…». |
| Success | A green check with «Success»; the page reloads and the visitor continues to the site. |
| Failure | A red cross with a «Try again» button, which gets the focus. |

Each state is announced to screen readers.

- Only a trusted browser input event counts (a click, a tap, or Space or Enter from the keyboard): a click synthesised by a script on the page, such as `element.click()`, or setting `checked` from a script is not trusted and doesn't submit anything. The edge also rejects a solve sent less than half a second after the challenge was issued, which stops instant replays; there is no minimum time between the box appearing and the click, because a person whose pointer already rests there clicks within milliseconds. A rejected attempt gets `403` with `interaction-required` from `/__waf/verify`, the box shows the failure state, and it counts as a failed challenge.
- The **ID** in the footer identifies this challenge page (it is derived from the challenge, not from the visitor, and contains nothing secret). If a visitor can't get through, ask them for it: OpenWAF support finds the verification attempts of that page with it.
- Browsers that report they are automated (`navigator.webdriver`) get the same box 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](#exemptions)) 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](#turn-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
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.com` calls `api.example.com`, set [`pass_scope: zone`](https://developers.openwaf.dev/waf/browser-challenge/#passes) so 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`](#hosts), exempt their paths, or allow their addresses with an IP or ASN `allow` rule.
- **Webhooks** from payment providers and other services: [exempt their paths](#exempt-webhooks).

`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](https://developers.openwaf.dev/waf/browser-challenge/#spa).

## 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](https://developers.openwaf.dev/waf/ip-and-country-rules/) | The zone's and the name's. Country `allow` rules don't exempt. |
| [ASN `allow` rules](https://developers.openwaf.dev/waf/ip-and-country-rules/#asn-rules) | Every address of the allowed network. |
| [WAF exceptions](https://developers.openwaf.dev/waf/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](https://developers.openwaf.dev/ai-crawl-control/verification/) 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:

```json
{"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:

```json
{"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 example `60`, 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](https://developers.openwaf.dev/change-control/) 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.

**Dashboard**

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 to tick a box** is on by default; turn 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.
**CLI**

```sh
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 verification box; 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`](https://developers.openwaf.dev/cli/reference/zones/#zones-under-attack).
**API**

```sh
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 verification box. 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`.
**MCP**

`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](#until): 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 a click on the [verification box](#interactive-step) 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](https://developers.openwaf.dev/waf/browser-challenge/#analytics). Solves through the verification box 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`.

The challenges of under attack mode are also [risk scored](https://developers.openwaf.dev/waf/verify-risk/), like any other: the report's risk section shows how they split by level. In risk scoring's enforce mode a high score can make the proof of work harder or block the challenge; the verification box itself doesn't depend on the score.

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.
