Bot-challenge middleware for Flask intercepts unrecognized visitors, issues proof-of-work or CAPTCHA challenges, and grants HMAC-signed JWT access cookies to solvers.
from flask import Flask
from flask_vouch import Vouch
app = Flask(__name__)
vouch = Vouch(app, secret="change-me")Bots get a browser challenge page. Humans solve it once, get a cookie, browse freely.
pip install flask-VouchOptional extras:
pip install flask-Vouch[image] # image-based captchas (Pillow, numpy)
pip install flask-Vouch[audio] # audio captcha (numpy, scipy)- Every suspicious unauthenticated request matching the configured rules is redirected to a challenge page.
- A proof-of-work challenge (SHA-256 Balloon by default) is issued.
- The browser solves it in JavaScript and POSTs to
/.tollbooth/verify. - A valid solution sets a signed JWT cookie subsequent requests pass through.
from flask import Flask
from flask_vouch import Vouch
app = Flask(__name__)
vouch = Vouch(app, secret="change-me")
@app.route("/")
def index():
return "You passed the challenge!"
@app.route("/internal")
@vouch.exempt
def internal():
return "ok"Application factory:
vouch = Vouch(secret="change-me")
def create_app():
app = Flask(__name__)
vouch.init_app(app)
return appSECRET_KEY fallback if no secret= is passed, app.config["SECRET_KEY"] is used automatically:
app.config["SECRET_KEY"] = "change-me"
vouch = Vouch()
vouch.init_app(app)Pass as kwargs or via app.config with the VOUCH_ prefix:
| Parameter | Default | Description |
|---|---|---|
secret |
SECRET_KEY |
HMAC/JWT signing key |
policy |
default rules | Policy instance |
exclude |
[] |
Path regexes to skip entirely |
json_mode |
False |
Return JSON challenge instead of HTML |
cookie_name |
_tollbooth |
Access cookie name |
cookie_ttl |
604800 |
Cookie lifetime in seconds (7 days) |
cookie_secure |
True |
Secure flag; only set over HTTPS |
verify_path |
/.tollbooth/verify |
Challenge verification endpoint |
challenge_handler |
SHA256Balloon |
Challenge implementation |
blocklist |
None |
NetSet instance or list of them |
app.config["VOUCH_COOKIE_NAME"] = "_v"
app.config["VOUCH_COOKIE_TTL"] = 3600| Decorator | Behavior |
|---|---|
@vouch.exempt |
Skip challenge entirely for this route |
@vouch.protect |
Always run challenge check (overrides global allow) |
@vouch.challenge |
Always issue a challenge regardless of policy |
@vouch.block |
Deny detected crawlers outright; challenge or pass others |
from flask_vouch import Vouch, Policy, Rule
policy = Policy(
rules=[
Rule(name="allow-google", action="allow", user_agent="Googlebot"),
Rule(name="block-scrapers", action="deny", user_agent="AhrefsBot|SemrushBot"),
Rule(name="challenge-curl", action="challenge", difficulty=8, user_agent="curl"),
]
)
vouch = Vouch(app, secret="s", policy=policy)Load the built-in ruleset from rules.json:
from flask_vouch import load_policy
vouch = Vouch(app, secret="s", policy=load_policy())Rule fields:
| Field | Type | Description |
|---|---|---|
name |
str |
Identifier |
action |
str |
allow · deny · challenge · weigh |
user_agent |
str (regex) |
Match on User-Agent header |
path |
str (regex) |
Match on request path |
headers |
dict |
Match on arbitrary headers (regex values) |
remote_addresses |
list[str] |
CIDR ranges to match |
difficulty |
int |
Challenge difficulty (default: policy) |
weight |
int |
Score added when action=weigh |
blocklist |
bool |
Match IPs in the loaded netset |
bogon_ip |
bool |
Match non-global / bogon IPs |
crawler |
bool |
Match detected crawler user agents |
from flask_vouch import (
SHA256Balloon, # default, proof of work (SHA-256 balloon hashing)
SHA256, # lightweight SHA-256 PoW
ChainCaptcha, # no-interaction iterated-SHA-256 PoW
CharacterCaptcha, # text CAPTCHA
ImageCaptcha, # image CAPTCHA (requires [image])
RotationCaptcha, # rotation CAPTCHA (requires [image])
CupCaptcha, # cup fill CAPTCHA (requires [image])
SlidingCaptcha, # sliding puzzle (requires [image])
CircleCaptcha, # circle select CAPTCHA (requires [image])
TraceCaptcha, # curve-trace CAPTCHA (pure Python, kinematics)
ImageGridCaptcha, # image grid CAPTCHA (requires [image])
AudioCaptcha, # audio CAPTCHA (requires [audio])
NavigatorAttestation, # browser signal attestation
QuirkProbe, # browser-engine quirk verification
ThirdPartyCaptchaChallenge, # embed external CAPTCHAs
)
vouch = Vouch(app, secret="s", challenge_handler=CharacterCaptcha())A netset is a newline-delimited list of IPs, CIDR ranges, or start-end
ranges (# comments ignored) the FireHOL ipset/netset format. NetSet
loads one from a file path or URL, merges overlapping ranges, and answers
membership in O(log n).
from flask_vouch import Vouch, NetSet
ns = NetSet() # defaults to bundled blocklist.netset URL
ns.load()
ns.start_updates() # auto-refresh daily in a daemon thread
vouch = Vouch(app, secret="s", blocklist=ns)Custom source(s) path or URL:
ns = NetSet("https://example.com/bad-ips.netset")
many = NetSet.from_sources(["a.netset", "b.netset"])
vouch = Vouch(app, secret="s", blocklist=[ns1, ns2])For multi-process / multi-worker deployments:
import redis
from flask_vouch.redis import RedisEngine
from flask_vouch import Vouch
r = redis.Redis()
engine = RedisEngine(r, secret="s")
vouch = Vouch(app, engine=engine)from flask_vouch.extras import ErrorHandler
eh = ErrorHandler(bouncer=vouch)
eh.init_flask(app)from flask_vouch.extras import RateLimiter
rl = RateLimiter(default="100/minute")
rl.init_flask(app)
@app.route("/login")
@rl.limit("5/minute")
def login(): ...from flask_vouch.extras import ThirdPartyCaptcha
tpc = ThirdPartyCaptcha(turnstile_site_key="...", turnstile_secret="...")
tpc.init_flask(app)
@app.route("/submit", methods=["POST"])
def submit():
if not tpc.is_turnstile_valid():
abort(403)
...pip install black isort
isort . && black .
npx prtfm