4.9 KiB
turnstile-caddy
Caddy v2 HTTP middleware that serves a managed Cloudflare Turnstile challenge for any request whose path matches a Disallow rule in the site’s own /robots.txt (User-agent: * group).
Well-behaved crawlers should never hit those paths; there is no good-bot allowlist. After a successful challenge, an HMAC-signed cookie skips further challenges for pass_duration. Optionally, set forgejo_cookie_name to skip challenges for authenticated Forgejo users: Authorization: token … or Authorization: Bearer … is validated with GET /api/v1/user (preferred), otherwise the Forgejo session cookie is validated with GET /user/settings. With forgejo_remember_cookie_name, the session pass cookie is also bound to the remember-me cookie so the bypass can survive a browser restart when the session cookie is gone.
Build
xcaddy build \
--with git.lschaefer.xyz/lukasdotcom/turnstile-caddy
Cloudflare setup
- In the Cloudflare dashboard, create a Turnstile widget in Managed mode.
- Add your site hostname to the widget.
- Copy the site key and secret key into environment variables (below).
Caddyfile
git.lschaefer.xyz {
turnstile {
site_key {$TURNSTILE_SITE_KEY}
secret_key {$TURNSTILE_SECRET_KEY}
cookie_secret {$TURNSTILE_COOKIE_SECRET}
pass_duration 1h
robots_refresh 24h
upstream http://127.0.0.1:3000
forgejo_cookie_name session # omit to disable Forgejo auth bypass
forgejo_remember_cookie_name persistent # optional; bind pass cookie to remember-me too
}
reverse_proxy 127.0.0.1:3000
}
Generate a cookie secret once:
openssl rand -hex 32
| Option | Required | Default | Description |
|---|---|---|---|
site_key |
yes | — | Turnstile site key |
secret_key |
yes | — | Turnstile secret key |
cookie_secret |
yes | — | HMAC key for the pass cookie |
cookie_name |
no | __turnstile_pass |
Pass cookie name |
pass_duration |
no | 1h |
How long a solved challenge lasts |
robots_refresh |
no | 24h |
How often to re-fetch /robots.txt |
challenge_path |
no | /__turnstile__/ |
Prefix for verify endpoint |
upstream |
yes | — | Upstream base URL for /robots.txt and Forgejo session validation (match reverse_proxy) |
forgejo_cookie_name |
no | — | Forgejo session cookie name; enables logged-in bypass (omit to disable Forgejo auth) |
forgejo_remember_cookie_name |
no | — | Forgejo remember-me cookie name ([security].COOKIE_REMEMBER_NAME); when set, the session pass cookie is also bound to it and may validate only if no session cookie is present |
session_pass_cookie |
no | __turnstile_session_pass |
HMAC pass cookie bound to the session token (only when Forgejo auth is enabled) |
Behavior
- On startup, the module fetches
{upstream}/robots.txtand parsesUser-agent: *Allow/Disallowrules. - Until the first successful fetch, requests fail-open (pass through). Refresh failures keep the last good rules.
/robots.txtand/__turnstile__/*are never challenged.- Matching Disallow paths get a Turnstile interstitial (
200HTML) with aX-Turnstile-Challengeheader:Anonymouswhen no Forgejo session/bearer credentials were present, orInvalidwhen those credentials were present but did not authenticate.POST /__turnstile__/verifycalls Cloudflaresiteverify, sets the pass cookie, and redirects to the original URL. Failed verification re-shows the same page. - When
forgejo_cookie_nameis set, authenticated Forgejo users skip challenges. If the request hasAuthorization: token …orAuthorization: Bearer …, validation usesGET /api/v1/useronupstream(Forgejo’s AuthorizationHeaderToken). Otherwise the Forgejo session cookie is checked withGET /user/settings; a200response containing the settings profile page counts as signed in and redirects are treated as unauthenticated. On success, an HMAC-signedsession_pass_cookieis set, bound to the current credentials (authorization header, and/or session cookie, and/or remember-me cookie whenforgejo_remember_cookie_nameis set) and Forgejo username. Later requests skip re-validation when the session cookie still matches its binding. If the session cookie is absent, the remember-me binding may validate instead. If the session cookie is present but differs from the stored binding, the pass cookie is rejected and the new session is re-verified upstream. Empty session or remember-me values never count as a match. The Forgejo username is stored as{http.auth.user.id}(same as caddy-security), so it appears in the dedicateduser_idfield of JSON access logs whenlogis enabled. Omitforgejo_cookie_nameto disable this bypass entirely.
CrowdSec (or other bouncers) can still ban IPs separately; this module only gates robots.txt Disallow paths.
Tests
cd turnstile-caddy && go test ./...