Autumn's bot protection middleware validates a CAPTCHA token on every application/x-www-form-urlencoded POST/PUT/PATCH/DELETE request before it reaches a handler. Requests that fail the challenge receive a 400 Bad Request Problem Details response; handlers never see them.

Two production providers are built in — Cloudflare Turnstile and hCaptcha — and both a dev-mode bypass and a deterministic test provider ship for local development and automated tests.


Quick start

1. Configure autumn.toml

TOML
[bot_protection]
enabled    = true
provider   = "turnstile"          # "turnstile" (default) or "hcaptcha"
site_key   = "0x4AAAA..."        # rendered into the widget; safe to commit
secret_key = "..."                # server-side secret — use an env var!
dev_bypass = false

Never commit secret_key. Set it via the environment:

Shell
export AUTUMN_BOT_PROTECTION__SECRET_KEY="your-secret"

2. Add the widget to your form

Rust
use autumn_web::prelude::*;

#[get("/signup")]
async fn signup_form(config: AutumnConfig) -> Markup {
    html! {
        form method="POST" action="/signup" {
            input type="email" name="email";
            (bot_protection_widget(&config.bot_protection))
            button { "Sign up" }
        }
    }
}

bot_protection_widget renders the provider-appropriate <div> placeholder and <script> tag. You do not need to add the script tag yourself.

3. Handlers stay clean

The middleware runs before your handler. If the CAPTCHA passes, the request arrives at the handler body-intact; if it fails, your handler is never called.

Rust
#[post("/signup")]
async fn signup_submit(form: Form<SignupForm>) -> impl IntoResponse {
    // Only reached when CAPTCHA verification succeeded.
    "Welcome!"
}

Configuration reference

KeyTypeDefaultDescription
enabledboolfalseEnable the middleware.
provider"turnstile" | "hcaptcha""turnstile"CAPTCHA backend.
site_keystringPublic widget key (rendered to the browser).
secret_keystringPrivate server-side verification secret.
form_fieldstringprovider defaultOverride the form field name scanned for the token.
dev_bypassboolfalseSkip verification; any request passes.

Provider defaults for form_field:

ProviderDefault field
Turnstilecf-turnstile-response
hCaptchah-captcha-response

Dev-mode bypass

In local development you generally do not want to complete a CAPTCHA on every form submission. Set dev_bypass = true:

TOML
# autumn.toml (dev profile)
[bot_protection]
enabled    = true
dev_bypass = true

With dev_bypass = true:

  • The middleware is still wired in (so integration tests catch middleware ordering bugs).
  • Verification is skipped; every request passes unconditionally.
  • bot_protection_widget renders a hidden <input> instead of the real widget, so HTML forms submit without requiring user interaction.

A startup log line confirms bypass is active:

Code
INFO  autumn_web::router: bot_protection provider=Turnstile dev_bypass=true

Provider switching

Switch providers by changing the provider key — no code changes needed:

TOML
[bot_protection]
enabled    = true
provider   = "hcaptcha"
site_key   = "your-hcaptcha-site-key"
secret_key = "your-hcaptcha-secret"

Both providers expose the same middleware interface. The widget helper automatically renders the correct <div> class and <script> source.


Test-mode bypass

For automated tests inject a deterministic provider instead of configuring real provider credentials:

Rust
use std::sync::Arc;
use autumn_web::security::captcha::{BotProtectionLayer, TestCaptchaProvider};
use autumn_web::test::TestApp;

// Only "correct-token" passes; every other value yields 400.
let layer = BotProtectionLayer::new(Arc::new(TestCaptchaProvider::new("correct-token")));

let client = TestApp::new()
    .routes(routes![submit])
    .layer(layer)
    .build();

// Correct token → 200
client
    .post("/submit")
    .header("Content-Type", "application/x-www-form-urlencoded")
    .body("cf-turnstile-response=correct-token")
    .send()
    .await
    .assert_status(200);

// Wrong token → 400
client
    .post("/submit")
    .header("Content-Type", "application/x-www-form-urlencoded")
    .body("cf-turnstile-response=bad-token")
    .send()
    .await
    .assert_status(400);

Use [AlwaysPassProvider] when you want every request to pass without a token:

Rust
let layer = BotProtectionLayer::new(Arc::new(AlwaysPassProvider));

Scope: only HTML forms are challenged

The middleware inspects only application/x-www-form-urlencoded requests. Other content types — JSON API calls, multipart file uploads, and most external webhooks — pass through without any CAPTCHA check. This means:

  • REST JSON endpoints are unaffected.
  • File upload endpoints (multipart/form-data) are unaffected.
  • Webhooks delivered as JSON (Stripe, GitHub, etc.) are unaffected.

Only classic HTML form submissions — the intended target — are verified.

URL-encoded webhook payloads (Slack slash commands, etc.)

Some webhook senders — most notably Slack slash commands — deliver payloads as application/x-www-form-urlencoded. Those requests will be challenged by the global middleware and will fail because they cannot include a CAPTCHA token.

The solution is to disable the global middleware (bot_protection.enabled = false in autumn.toml) and apply the layer manually only to the router that serves your public forms. Use BotProtectionLayer::new() with an explicitly constructed provider so the scoped layer is enforcing:

TOML
# autumn.toml — disable global auto-wiring; we apply the layer manually below
[bot_protection]
enabled  = false
site_key = "0x4AAAA..."
# Use AUTUMN_BOT_PROTECTION__SECRET_KEY env var for the secret in production

Widget rendering with enabled = false

bot_protection_widget renders the CAPTCHA widget whenever site_key is configured, regardless of the enabled flag. Setting enabled = false only disables the global auto-wired middleware — it does not suppress the widget. Forms in the manually-layered router will correctly render and submit the CAPTCHA token.

Rust
use autumn_web::prelude::*;
use autumn_web::security::captcha::{BotProtectionLayer, TurnstileProvider};
use std::sync::Arc;

// Read the secret from the loaded config (populated from the env var).
let secret = config.bot_protection.secret_key.clone().unwrap_or_default();

// Public form routes — CAPTCHA enforced via an explicit layer.
// .with_form_field ensures the layer scans the same field name that
// bot_protection_widget emits (important when bot_protection.form_field is set).
let forms_router = Router::new()
    .route("/signup", post(signup_submit))
    .layer(
        BotProtectionLayer::new(Arc::new(TurnstileProvider::new(secret)))
            .with_form_field(config.bot_protection.effective_form_field()),
    );

// Webhook routes — no CAPTCHA (signature verification happens inside the handler).
let webhook_router = Router::new()
    .route("/webhooks/slack", post(slack_handler));

let app = Router::new()
    .merge(forms_router)
    .merge(webhook_router);

With this layout the CAPTCHA middleware is only applied to the routes that need it and the webhook endpoints are left untouched. The bot_protection.site_key is read by bot_protection_widget in templates as normal — the widget renders the full Turnstile <div> and <script> regardless of enabled.


Content Security Policy

Autumn's default CSP (default-src 'self'; script-src 'self'; ...) blocks the provider scripts and iframes loaded by bot_protection_widget. When bot protection is enabled you must extend the CSP to allow the provider's origins.

Add the following to your autumn.toml security headers config:

Turnstile:

TOML
[security.headers]
content_security_policy = """
  default-src 'self';
  script-src 'self' https://challenges.cloudflare.com;
  frame-src https://challenges.cloudflare.com;
"""

hCaptcha:

TOML
[security.headers]
content_security_policy = """
  default-src 'self';
  script-src 'self' https://hcaptcha.com https://*.hcaptcha.com;
  frame-src https://hcaptcha.com https://*.hcaptcha.com;
  style-src 'self' https://hcaptcha.com https://*.hcaptcha.com;
  connect-src https://hcaptcha.com https://*.hcaptcha.com;
"""

The wildcard *.hcaptcha.com covers all asset subdomains used by the widget; hCaptcha's official CSP guidance recommends this over hard-coding specific subdomains which may change.

Or extend the default CSP programmatically by calling default_content_security_policy() and appending the required sources.


Edge cases

No-JS clients

Cloudflare Turnstile supports an invisible mode that completes without any user gesture, making it compatible with most no-JS-unfriendly clients at the server level. However, the JavaScript widget itself requires a browser to execute.

For clients that truly cannot run JavaScript (CLI tools, server-to-server calls), bot protection applies only to form submissions. API endpoints using application/json are not challenged by this middleware. If you need to protect API endpoints, consider rate limiting ([security.rate_limit]) or Authorization header validation instead.

Accessibility

Both Turnstile and hCaptcha expose accessibility-friendly CAPTCHA modes:

  • Cloudflare Turnstile is designed to be invisible for most users and requires no puzzle-solving interaction for well-behaved browsers. It is WCAG 2.1 compatible.
  • hCaptcha offers an accessibility cookie that lets users with disabilities bypass challenges site-wide.

Neither provider requires users to identify objects in images by default in their modern widget modes.

Replay / double-submission protection

CAPTCHA tokens are single-use on the provider side. Submitting the same token twice will fail verification on the second attempt. Ensure your forms do not replay requests on network errors without generating a fresh token.

Missing secret_key

If enabled = true and dev_bypass = false but secret_key is missing, Autumn logs a warning at startup:

Code
WARN  autumn_web::security::captcha: bot_protection: enabled is true and dev_bypass is false,
      but secret_key is missing or empty — all CAPTCHA verifications will fail!

Every mutating form request will then receive a 400 Bad Request. Always supply secret_key in production or set dev_bypass = true in development.


Custom providers

Implement [CaptchaProvider] to integrate any third-party CAPTCHA service:

Rust
use std::{future::Future, pin::Pin, sync::Arc};
use autumn_web::security::captcha::{BotProtectionLayer, CaptchaProvider};

struct MyProvider;

impl CaptchaProvider for MyProvider {
    fn verify<'a>(&'a self, token: &'a str) -> Pin<Box<dyn Future<Output = bool> + Send + 'a>> {
        Box::pin(async move {
            // Call your CAPTCHA service here.
            !token.is_empty()
        })
    }

    fn form_field_name(&self) -> &str {
        "my-captcha-token"
    }

    #[cfg(feature = "maud")]
    fn widget_markup(&self, site_key: &str) -> maud::Markup {
        maud::html! {
            div data-my-captcha=(site_key) {}
            script src="https://example.com/captcha.js" {}
        }
    }
}

let layer = BotProtectionLayer::new(Arc::new(MyProvider));

Pass the layer to AppBuilder::layer (or TestApp::layer in tests) and it plugs straight into the middleware stack.