Challenge protocol
This page is only relevant if you maintain a remediation component and want it to serve bot-detection challenges. To use bot detection with an already-compatible bouncer, see Enable bot detection.
Bot detection extends the WAF / bouncer communication protocol with an action next to allow, ban and captcha: challenge. Where ban and captcha are verdicts the remediation component renders from its own templates, a challenge is a complete HTTP response — status, body, headers and cookies — generated by the AppSec component and relayed to the browser unchanged.
Two properties set it apart from the other remediations:
- The body is large and generated per request. The challenge page carries the fingerprinting and proof-of-work JavaScript, so there is no static template to ship. A component whose control channel cannot carry a body that size must serve it over a regular HTTP path instead of trying to squeeze it through.
- The exchange spans several requests. The browser fetches assets and posts its proof back to internal URLs that the AppSec component serves itself. The remediation component must forward those to the AppSec component rather than to the origin.
The challenge action
When the AppSec component decides to challenge a request, it answers the remediation component with HTTP 403 — the usual blocked status from the base protocol — and a JSON envelope:
{
"action": "challenge",
"http_status": 200,
"user_body_content": "<!DOCTYPE html>...",
"user_headers": {
"Content-Type": ["text/html; charset=utf-8"],
"Cache-Control": ["no-cache, no-store"],
"Content-Security-Policy": ["default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; worker-src 'self' blob:;"]
},
"user_cookies": [
"__crowdsec_challenge=...; Path=/; HttpOnly; SameSite=Lax"
]
}
| Field | Meaning |
|---|---|
action | Always challenge here. A 403 carrying any other action is that other remediation. |
http_status | The status code to return to the browser — not the 403 the remediation component received. Defaults to 200 if absent or zero. |
user_body_content | The body to write to the browser: the challenge HTML, a JavaScript asset, or a small JSON status document. |
user_headers | Response headers to set on the browser response. A Content-Security-Policy is always present — the AppSec component injects a permissive default when no hook set one, because the challenge needs inline script and blob workers to run. |
user_cookies | Ready-to-send Set-Cookie values. Each entry is one header. |
http_status is not always 200. GrantChallengeCookie() answers with 307 plus a Location header in user_headers, so a remediation component that hardcodes 200 breaks the allowlist-bypass flow. Honour whatever http_status says.
For a component that can write the response inline, that is the whole integration: take the four fields and write them to the client. The nginx and OpenResty bouncers, for instance, apply the status, add every header, add each cookie as its own Set-Cookie, print the body, and stop the request there.
Internal challenge endpoints
The challenge runtime serves a handful of URLs itself. The remediation component must forward them to the AppSec component unmodified, and must never pass them to the protected origin:
| Path | Method | Purpose |
|---|---|---|
/crowdsec-internal/challenge/fpscanner.js | GET | The fingerprinting bundle. |
/crowdsec-internal/challenge/pow-worker.js | GET | The proof-of-work worker. |
/crowdsec-internal/challenge/submit | POST | Proof-of-work result and encrypted fingerprint. |
All three come back in exactly the same envelope as the challenge page: HTTP 403 to the remediation component, action: challenge, http_status: 200. This is true even when the submission succeeds — a solved challenge is not an allow, it is a challenge envelope whose user_cookies carries the sealed success cookie, with one of these bodies:
| Body | Meaning |
|---|---|
{"status":"ok"} | Proof accepted. user_cookies carries __crowdsec_challenge. |
{"status":"failed"} | Proof invalid (bad PoW, bad ticket, bad HMAC, expired epoch). No cookie. |
{"status":"rejected"} | Proof valid, but an on_challenge_submit hook called RejectSubmission(). No cookie. |
A remediation component that drops Set-Cookie on the submit response leaves the browser with nothing to replay, and the visitor is challenged forever. Two related requirements:
- The
POSTbody of a submit request must be forwarded to the AppSec component, so this request is relayed as aPOSTper the base protocol. - Subsequent requests must keep carrying the browser's
Cookieheader through to the AppSec component — that is how a solved challenge is recognised and answered withallow.
The remediation component never parses, validates or mints challenge cookies. They are sealed by the AppSec component under the master secret and are opaque to everything else.
Requirements for a remediation component
-
Forward
/crowdsec-internal/challenge/*to the AppSec component, unmodified, and never to the origin. -
Return
http_statusto the browser, not the403received from the AppSec component. -
Set every
user_headersentry, and emit eachuser_cookiesentry as a separateSet-Cookieheader — never joined with commas. -
Forward the request body on
POSTsubmissions. -
Send the real client IP in
X-Crowdsec-Appsec-Ipon every request, challenge assets and submissions included. The AppSec component uses it as the client IP for Coraza, allowlists, country rules and theclient_ipof every event it emits, so a proxy address here silently attributes all challenge traffic to your own infrastructure. -
Fail closed: if the challenge cannot be served, treat the request as
ban. -
Treat
challengeas more restrictive thancaptchaand less restrictive thanban, when picking the most restrictive of several remediations:TEXTallow < unknown < captcha < challenge < ban -
Only an AppSec-issued
challengecan be served. Achallengedecision arriving from a blocklist or from the LAPI carries no body, headers or cookies, so there is nothing to render — fail it closed toban.
Failure behaviour
| AppSec response | Remediation component behaviour |
|---|---|
200 | Allow the request. |
403, action: challenge, non-empty user_body_content | Serve the challenge response. |
403, action: challenge, empty user_body_content | Fail closed — ban. |
403, any other action | Apply that action (ban, captcha, …). |
403 with an empty body | ban. |
403 with invalid JSON | ban. |
401, 500, or an unexpected status | The component's configured AppSec failure behaviour (APPSEC_FAILURE_ACTION). |
Security notes
- The client IP sent in
X-Crowdsec-Appsec-Ipmust come from a source the component itself controls. If it is read from a forwarding header, that header must be overwritten by the proxy on every request — a client-supplied value here decides allowlisting and country rules. - Emit
Set-Cookieas repeated headers, and forward the browser'sCookieheader untouched. - Do not leak the AppSec JSON envelope to the browser on a block: return a plain
403instead. - The internal challenge paths must be terminated by the remediation component. If they reach the origin, the challenge is bypassable by simply requesting them.
See also
- How it works — the challenge exchange from the visitor's point of view.
- WAF / Bouncer communication protocol — headers, status codes and the base envelope.
- Bot detection configuration — master secret, key rotation and cookie TTL.
- Hooks reference —
SendChallenge(),GrantChallengeCookie(),RejectSubmission().