Challenge Response Type¶
response: challenge serves an interstitial (a CAPTCHA-style proof-of-effort page) when a plugin matches, instead of rejecting the request outright. A visitor who solves the challenge is issued an HMAC-signed pass token that short-circuits any future response: challenge plugin until the token expires.
The pass token is:
- Signed with the configured
challenge.secret(HMAC-SHA256) so it cannot be forged. - IP-bound — the token only verifies for the same client IP that solved the challenge.
- Audience-bound — the token carries an
audclaim and only verifies against the instance that issued it. See Scoping tokens across instances. - Delivered two ways — as an
HttpOnly; Secure; SameSite=Strictcookie and as a value the interstitial JS writes tolocalStorageso SPA callers can attach it to XHRs via a custom header (defaults toX-Firewall-Challenge). - Expires after
metadata.default_expiration_timeseconds for the matched plugin (default3600).
Minimum configuration¶
challenge:
provider: math # 'math' is the built-in; or a FQCN that implements ChallengeProviderInterface
secret: "${FIREWALL_SECRET}" # REQUIRED. Long random string, ideally from an env var.
cookie_name: fw_challenge_pass
header_name: X-Firewall-Challenge
path: /_firewall/challenge # The URL the interstitial POSTs to
plugins:
- plugin: "Kanopi\\Firewall\\Plugins\\Asn"
response: challenge
weight: -10
enable: true
metadata:
default_expiration_time: 3600 # Pass token TTL in seconds
config:
- "asn:AS14618" # Show the challenge to AWS traffic
If any plugin uses response: challenge, challenge.secret is required. Startup fails fast with ConfigurationException when it is empty — the firewall will not silently fall back to plaintext tokens.
Single-use solutions¶
A stateless provider verifies a solution purely from the posted payload, so the same payload keeps verifying until it expires. For a proof-of-work challenge that quietly defeats the point: an attacker solves one challenge and hands the payload to as many clients as they like, each minting its own IP-bound pass token, and the per-solve cost is amortised to nothing.
Providers can opt out of that by implementing Kanopi\Firewall\Challenge\SingleUseSolutionInterface, which hands the firewall an identifier for the solution in the current request. The firewall records it in the configured storage backend and refuses any later submission carrying the same identifier. altcha implements this; a solved payload is accepted exactly once.
Two consequences worth knowing:
- The challenge flow now writes to storage. Records are small (one per solved challenge) and expire on their own when the underlying challenge would have gone stale. With
InMemoryStoragethey do not survive the process, so use a shared backend if you serve challenges from more than one worker. - The check is read-then-write, not atomic. Two submissions of the same solution arriving in the same instant can both succeed. This shrinks the reuse window from the full challenge lifetime to microseconds, which is the part that matters — the attack being closed is redistribution over seconds or minutes, not winning a race.
math deliberately does not implement it: its signed state is answer|expiry, and with only nine possible answers two visitors served in the same second routinely share one, so treating that value as single-use would reject legitimate solvers.
Scoping tokens across instances¶
A pass token attests "this client solved a challenge" — so if two Firewall instances share a challenge.secret, they would accept each other's tokens without further scoping. That matters when the challenges differ in strength: a token earned on the trivial math challenge could otherwise be replayed against a route protected by altcha, and the weakest challenge in your deployment would set the effective security of every route that shares the secret.
Tokens therefore carry an aud claim, which defaults to the configured provider name and is covered by the signature. A math token will not verify against an altcha instance.
If you run the same provider in several places with the same secret — say a low-value public route and a sensitive admin area — the default audiences are identical, so set them apart explicitly:
challenge:
provider: altcha
secret: "${FIREWALL_SECRET}"
audience: admin-portal # defaults to the provider name
The alternative is to give each instance its own challenge.secret, which isolates them just as effectively.
Upgrade note. Pass tokens minted before the
audclaim existed are rejected, because verification fails closed rather than treating a missing audience as a match. The visible effect is that everyone holding a live pass token is challenged once more after deploying. Tokens are short-lived (default one hour), so this clears on its own.
Built-in providers¶
Two providers ship with the firewall — set challenge.provider to either short name:
Both screenshots come from the demo application, which serves each provider on its own route.
math— asks "What is A + B?" with single-digit operands. Low-friction proof-of-effort, no JS bundle, no external script load. Defeats the laziest bots; trivial for a human.altcha— embeds the ALTCHA v2 widget with a pre-computed challenge (no server round-trip to fetch one). The visitor's browser brute-forcesSHA-256(salt + N) == challenge; the salt embeds an expiry and the challenge is HMAC-signed withchallenge.secret, so the server stays stateless. Privacy-respecting, and imposes a per-solve CPU cost on bots. Solved challenges are single-use — see Single-use solutions.
The widget script is pinned to an exact version and served with a Subresource Integrity digest. To self-host it, or to serve it from a host your CSP already allows, set both options — supplying widget_src without widget_integrity emits no integrity attribute, since a digest that does not match the bytes would block the script entirely:
challenge:
provider: altcha
provider_options:
widget_src: /assets/altcha.min.js
widget_integrity: 'sha384-…' # openssl dgst -sha384 -binary altcha.min.js | openssl base64 -A
The bundle is an ES module, so it is loaded with <script type="module">. If you host it yourself, keep that in mind: a classic script tag fails with Unexpected token 'export'.
For stronger bot resistance (Turnstile, hCaptcha, reCAPTCHA, etc.), implement Kanopi\Firewall\Challenge\ChallengeProviderInterface and set challenge.provider to its FQCN.
Writing a custom provider¶
A provider owns both halves of the round-trip: rendering the interstitial, and verifying what comes back. Providers must be stateless — embed whatever you need to verify the answer in the interstitial itself (a hidden field, signed with the shared TokenManager) rather than storing a per-challenge record. That is what lets the firewall scale horizontally without a shared session store.
<?php
namespace App\Firewall;
use Kanopi\Firewall\Challenge\ChallengeProviderInterface;
use Kanopi\Firewall\Challenge\TokenManager;
use Symfony\Component\HttpFoundation\Request;
class TurnstileProvider implements ChallengeProviderInterface
{
// The factory passes the shared TokenManager to every provider. Use it
// to sign your own per-challenge state; ignore it if you don't need to.
public function __construct(private readonly TokenManager $tokenManager)
{
}
public function getName(): string
{
return 'turnstile';
}
public function renderInterstitial(Request $request, array $context): string
{
// $context carries: submit_url, redirect_to, ttl, cookie_name, header_name.
// Echo redirect_to and ttl back as hidden fields — the Firewall reads
// them off the POST to size and target the pass token.
return <<<HTML
<!DOCTYPE html>
<html lang="en"><body>
<form method="post" action="{$context['submit_url']}">
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
<input type="hidden" name="redirect_to" value="{$context['redirect_to']}">
<input type="hidden" name="ttl" value="{$context['ttl']}">
<button type="submit">Continue</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script>
</body></html>
HTML;
}
public function verifySolution(Request $request): bool
{
$token = (string) $request->request->get('cf-turnstile-response', '');
// Verify server-side against the provider's siteverify endpoint.
// Return FALSE on any failure — never throw.
return $token !== '' && $this->verifyWithCloudflare($token, $request);
}
}
challenge:
provider: "App\\Firewall\\TurnstileProvider"
secret: "${FIREWALL_SECRET}"
path: /_firewall/challenge
Requirements and gotchas:
- Escape everything you interpolate.
redirect_tooriginates from the request URI. The built-inMathChallengeProviderruns every substitution throughhtmlspecialchars(); do the same. - Echo back
redirect_toandttlas form fields named exactly that. The Firewall reads them from the POST to decide where to send the visitor and how long to mint the pass token for. Omit them and you get/and 3600s. verifySolution()must never throw. It runs on attacker-controlled input; returnfalsefor anything you don't like.- Register via FQCN, not a short name.
challenge.provideronly resolvesmathas a built-in; everything else must be a loadable class implementing the interface, orcreate()throwsConfigurationException. - The constructor signature is fixed —
ChallengeProviderFactoryalways callsnew $class($tokenManager). Read any further configuration from your own environment or constants. - Use
$this->tokenManager->sign()/verifySignature()if you need tamper-proof state in the form. You do not need to mint the pass token — the Firewall does that onceverifySolution()returnstrue.
How dispatch interacts with allow / block¶
| Visitor state | Result |
|---|---|
Matched by an allow plugin | Allowed (challenge skipped). |
Holds a valid pass token + matches challenge | Allowed (challenge bucket skipped). |
No token, matches a challenge plugin | Interstitial served; original URL is remembered for the post-success redirect. |
Matches a block plugin | Blocked, even if a valid pass token is held. |

