Skip to main content
Security Engine version:
Version: Next

Challenge protocol

warning

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:

JSON
{
"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"
]
}
FieldMeaning
actionAlways challenge here. A 403 carrying any other action is that other remediation.
http_statusThe status code to return to the browser — not the 403 the remediation component received. Defaults to 200 if absent or zero.
user_body_contentThe body to write to the browser: the challenge HTML, a JavaScript asset, or a small JSON status document.
user_headersResponse 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_cookiesReady-to-send Set-Cookie values. Each entry is one header.
note

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:

PathMethodPurpose
/crowdsec-internal/challenge/fpscanner.jsGETThe fingerprinting bundle.
/crowdsec-internal/challenge/pow-worker.jsGETThe proof-of-work worker.
/crowdsec-internal/challenge/submitPOSTProof-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:

BodyMeaning
{"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 POST body of a submit request must be forwarded to the AppSec component, so this request is relayed as a POST per the base protocol.
  • Subsequent requests must keep carrying the browser's Cookie header through to the AppSec component — that is how a solved challenge is recognised and answered with allow.

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_status to the browser, not the 403 received from the AppSec component.

  • Set every user_headers entry, and emit each user_cookies entry as a separate Set-Cookie header — never joined with commas.

  • Forward the request body on POST submissions.

  • Send the real client IP in X-Crowdsec-Appsec-Ip on every request, challenge assets and submissions included. The AppSec component uses it as the client IP for Coraza, allowlists, country rules and the client_ip of 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 challenge as more restrictive than captcha and less restrictive than ban, when picking the most restrictive of several remediations:

    TEXT
    allow < unknown < captcha < challenge < ban
  • Only an AppSec-issued challenge can be served. A challenge decision arriving from a blocklist or from the LAPI carries no body, headers or cookies, so there is nothing to render — fail it closed to ban.

Failure behaviour

AppSec responseRemediation component behaviour
200Allow the request.
403, action: challenge, non-empty user_body_contentServe the challenge response.
403, action: challenge, empty user_body_contentFail closed — ban.
403, any other actionApply that action (ban, captcha, …).
403 with an empty bodyban.
403 with invalid JSONban.
401, 500, or an unexpected statusThe component's configured AppSec failure behaviour (APPSEC_FAILURE_ACTION).

Security notes

  • The client IP sent in X-Crowdsec-Appsec-Ip must 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-Cookie as repeated headers, and forward the browser's Cookie header untouched.
  • Do not leak the AppSec JSON envelope to the browser on a block: return a plain 403 instead.
  • 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

CrowdSec Docs
We use cookies

This site uses cookies to help us improve your experience. You can accept or decline below.