𐌅𐌋𐌀𐌔𐌊-ᕓꝊ𐌵𐌂𐋅
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.
Install
pip install flask-Vouch
Python 3.9 or newer, Flask 2.0 or newer.
Optional extras:
pip install flask-Vouch[image] # image-based captchas (Pillow, numpy)
pip install flask-Vouch[audio] # audio captcha (numpy, scipy)
How it works
- 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
/.vouch/verify. - A valid solution sets a signed JWT cookie subsequent requests pass through.
Behind a proxy, set trusted_proxies so the client address (used for rate limits
and challenge binding) is read from X-Forwarded-For only where that header is
actually trustworthy, see Behind a proxy.
Quick start
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 app
SECRET_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)
Configuration
Pass as kwargs or via app.config with the VOUCH_ prefix:
| Parameter | Default | Description |
|---|---|---|
secret |
SECRET_KEY |
HMAC/JWT signing key, 16 bytes minimum |
policy |
default rules | Policy instance |
exclude |
[] |
Path regexes to skip entirely |
json_mode |
False |
Return JSON challenge instead of HTML |
trusted_proxies |
None |
Proxy hop count, or the networks they use |
cookie_name |
_vouch |
Access cookie name |
cookie_ttl |
604800 |
Cookie lifetime in seconds (7 days) |
cookie_secure |
True |
Secure flag; only set over HTTPS |
cookie_samesite |
Lax |
SameSite flag |
bind_ip |
False |
Tie the cookie to the solver's IP |
verify_path |
/.vouch/verify |
Challenge verification endpoint |
challenge_handler |
SHA256Balloon |
Challenge implementation |
template_dir |
None |
Directory of challenge pages overriding the built-ins |
cookie_domain |
None |
Domain flag, to share the cookie across subdomains |
cookie_refresh |
True |
Reissue the cookie once it is past half its life |
default_difficulty |
10 |
Difficulty when a rule sets none |
challenge_threshold |
5 |
Weight at which weigh rules trigger a challenge |
deny_threshold |
0 |
Weight at which a request is denied, 0 disables it |
difficulty_step |
4 |
Weight per extra point of difficulty |
max_difficulty_bonus |
6 |
Ceiling on that escalation |
verify_bots |
True |
Confirm crawler claims by reverse DNS |
on_decision |
None |
(action, request, rule) callback per decision |
challenge_ttl |
1800 |
Seconds an unsolved challenge stays valid |
max_challenge_requests |
10 |
Challenges per IP per window |
max_challenge_failures |
3 |
Failed solutions per IP per window |
rate_limit_window |
300 |
Seconds both limits are measured over |
branding |
True |
Show the footer credit on challenge pages |
accent_color |
#44ff88 |
Challenge page accent |
blocklist |
None |
NetSet instance or list of them |
app.config["VOUCH_COOKIE_NAME"] = "_v"
app.config["VOUCH_COOKIE_TTL"] = 3600
bind_ip is off by default: a cookie keeps working when a phone switches
towers or a laptop changes network. Turn it on when session theft matters more
than those re-challenges. The challenge itself is always IP-bound, so a solution
cannot be farmed out to another address.
cookie_refresh keeps an active visitor from being re-challenged on a hard
expiry: past half of cookie_ttl the cookie is reissued on the next request, so
only real absence runs it out.
Behind a proxy
X-Forwarded-For is ignored unless trusted_proxies says how far to trust it,
otherwise anyone could set the header and sidestep the per-IP limits.
vouch = Vouch(app, secret="s", trusted_proxies=1) # one proxy hop
vouch = Vouch(app, secret="s", trusted_proxies=["10.0.0.0/8"]) # proxy networks
With a hop count the address written by your own proxy is used; anything a client prepended to the header is skipped. With networks the chain is walked from the right until an address outside them is found.
Route decorators
| 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 |
exempt also takes an endpoint name, vouch.exempt("static") for static files,
vouch.exempt("admin.static") for a blueprint's.
Request claims
Anything that passes the bouncer gets flask.g.vouch:
@app.route("/")
def index():
claims = flask.g.vouch
if claims.is_crawler:
return f"hello {claims.crawler_name}"
return "hello"
| Field | Meaning |
|---|---|
is_crawler |
User agent looks like a crawler |
crawler_name |
Crawler name when one could be read, else None |
matched_rule |
Name of the rule that allowed the request, None if none matched |
blocklist_match |
Matching blocklist range, when the rule matched on blocklist |
score |
Attestation score, for handlers that produce one |
cid |
Id of the solved challenge, on cookie-carrying requests |
Metrics
Every decision is counted, and on_decision forwards it wherever you keep
telemetry:
vouch.metrics.snapshot() # {"allow": 91, "challenge": 12, "pass": 480, ...}
vouch.metrics.reset()
def record(action, request, rule):
statsd.increment(f"vouch.{action}", tags=[f"rule:{rule.name if rule else 'none'}"])
vouch = Vouch(app, secret="s", on_decision=record)
| Action | Meaning |
|---|---|
pass |
Valid access cookie, no evaluation needed |
allow |
A rule, or too little weight, let it through |
challenge |
A challenge page was issued |
deny |
Rule or deny_threshold refused the request |
verified |
A challenge was solved |
failed |
A wrong solution was posted |
limited |
A per-IP rate limit rejected the request |
Custom rules
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 |
missing_headers |
list[str] |
Match when all named headers are absent or empty |
blocklist |
bool |
Match IPs in the loaded netset |
bogon_ip |
bool |
Match non-global / bogon IPs |
crawler |
bool |
Match detected crawler user agents |
verified_bot |
bool |
Match only crawlers proven by reverse DNS |
headers needs the header to be present, missing_headers is the opposite test,
so absent and empty both count. That is what catches a browser user agent sending
no Accept-Language, no Sec-Fetch-* and no Sec-CH-UA.
How rules are scored
The first allow or deny match ends evaluation. A challenge match is held
instead, so the remaining weigh rules still run: each difficulty_step of
weight adds a point of difficulty up to max_difficulty_bonus, and weight
reaching deny_threshold (0, off) denies outright. With no challenge match,
weight at or above challenge_threshold issues one at default_difficulty plus
the same bonus.
So a plain browser user agent gets the default challenge; the same request
missing Accept, Sec-Fetch-* and Sec-CH-UA gets a much harder one.
Verified crawlers
Allowing Googlebot by user agent allows anyone who types it. verified_bot rules
match only on forward-confirmed reverse DNS: the PTR name must belong to the
operator (.googlebot.com, .search.msn.com, ...) and resolve back to the same
address. Cached an hour per address; operators live in
flask_vouch.crawlers.OPERATOR_DOMAINS.
The default ruleset pairs an allow for verified crawlers with a deny for
everything else claiming their names:
Rule(name="verified", action="allow", verified_bot=True, user_agent="Googlebot"),
Rule(name="impostors", action="deny", user_agent="Googlebot"),
verify_bots=False skips the lookups; verified_bot rules then match on the
user agent alone.
from flask_vouch import is_verified_bot
is_verified_bot("Googlebot/2.1", "66.249.66.1") # True
is_verified_bot("Googlebot/2.1", "8.8.8.8") # False
Challenge types
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
MotionAttestation, # pointer-movement attestation, nothing to solve
FullAttestation, # proof of work + navigator + motion on one page
QuirkProbe, # browser-engine quirk verification
ThirdPartyCaptchaChallenge, # embed external CAPTCHAs
)
vouch = Vouch(app, secret="s", challenge_handler=CharacterCaptcha())
Attestation challenges
Three handlers clear a visitor on evidence rather than a puzzle.
NavigatorAttestation reads the browser's own internals over several rounds.
MotionAttestation asks for nothing at all: it watches the ordinary mouse, touch,
keyboard and scroll activity that happens anyway and scores how it moved.
FullAttestation runs all three layers on one page (a SHA-256 proof of work mined
in Web Workers, the navigator signals, and the movement) and clears the visitor only
when every layer agrees.
from flask_vouch import FullAttestation, MotionAttestation, Vouch
vouch = Vouch(app, secret="s", challenge_handler=MotionAttestation())
vouch = Vouch(
app,
secret="s",
challenge_handler=FullAttestation(
interactive=True, # show the "I am human" button, which produces
watch_seconds=6, # a real approach path; False watches quietly
min_navigator=0.6, # navigator score needed to pass
trusted_navigator=0.85, # score that carries a visitor who barely moved
min_motion=0.5, # movement score needed to pass
),
)
Both watch quietly when interactive=False, submitting on their own once enough
movement has been seen, or after a short deadline.
A FullAttestation pass needs all of: a valid proof of work, a navigator score at or
above min_navigator (rising with difficulty), and a motion verdict of human, or
suspicious movement carried by a navigator score at or above trusted_navigator.
Scores land in the cookie claims as score (navigator) and motion.
Movement is scored in five categories (path kinematics, trace texture, injected-input
dispatch, transcript contract and evidence), with any conclusive category capping the
whole score, so one synthetic channel is not averaged away. Measured against the
motion-attestation corpus: 25 of 27 real captures read human, and 11 of 14 path
generators read suspicious or bot. Kinematically faithful mimics still clear the
movement layer on geometry alone, which is what the proof of work and the navigator
signals are there for.
Custom challenge pages
Every challenge ships a page; replace it per handler or per directory. A Path
is read from disk, a str is the page itself. Pages share fragments through
{{include:name.js}}, resolved against the template directory then the bundled
one. navigator_signals.js, motion_collector.js and sha256_miner.js are the
shipped fragments:
from pathlib import Path
vouch = Vouch(app, secret="s", challenge_handler=SHA256(template=Path("wall.html")))
vouch = Vouch(app, secret="s", template_dir="templates/vouch")
template_dir is looked up by challenge type, so templates/vouch/sha256.html
replaces the SHA-256 page and any type without a file there keeps the built-in
one. A handler's own template wins over the directory. Files are read once and
cached, restart to pick up edits.
Copy a bundled page from flask_vouch/challenges/templates/ as a starting point.
Every placeholder below is replaced where it appears, a page only needs the ones
it uses:
| Placeholder | Contents |
|---|---|
{{CHALLENGE_DATA}} |
Whole payload as JSON, escaped for embedding in <script> |
{{<payload key>}} |
One key of render_payload, HTML-escaped ({{id}}, {{image}}, ...) |
{{ACCENT_COLOR}} |
accent_color from the policy |
{{BRANDING}} |
Footer credit, empty when branding=False |
{{ERROR}} |
Retry message after a wrong answer, empty otherwise |
Proof-of-work and attestation pages take the JSON blob because their scripts need
it; the CAPTCHA pages read individual keys. {{ERROR}} only ever gets filled on
handlers whose retry_on_failure is true, which is every CAPTCHA.
The page must POST id, nonce and redirect to verifyPath. Two-coordinate
answers may post nonce.x and nonce.y instead of nonce.
Custom handlers
Subclass ChallengeHandler, only challenge_type, verify and render_payload
are required. to_difficulty applies the type's offset from DIFFICULTY_OFFSETS,
and the page is read from challenges/templates/<challenge-type>.html unless a
template is set.
from flask_vouch.challenges import ChallengeHandler, ChallengeType
class MyChallenge(ChallengeHandler):
@property
def challenge_type(self):
return ChallengeType.SHA256
def verify(self, random_data, nonce, difficulty):
return str(nonce) == random_data[:4]
def render_payload(self, challenge, verify_path, redirect):
return {"id": challenge.id, "verifyPath": verify_path, "redirect": redirect}
CAPTCHAs that must hand the browser a page while keeping the answer server-side
subclass SignedTokenHandler: issue_token(solution) returns the encrypted,
HMAC-signed, expiring string stored as the challenge's random_data, and
read_token(token) returns the solution back (raising once token_ttl passes).
Handlers that render media additionally keep it in a RenderCache between
generate_random_data and render_payload.
A challenge is one-shot: the first answer submitted for it consumes it, right or
wrong, so one id cannot be used to guess repeatedly. A wrong answer is served a
fresh challenge when the handler sets retry_on_failure.
A handler raising during generation, rendering or verification is logged on the
flask_vouch logger and answered with 503, the rest of the site keeps serving.
IP netset
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. from_sources loads what it builds, since an
unloaded netset matches nothing; pass load=False to defer:
ns = NetSet("https://example.com/bad-ips.netset")
ns.load()
many = NetSet.from_sources(["a.netset", "b.netset"])
vouch = Vouch(app, secret="s", blocklist=many)
A netset queried before load() answers False for every address and logs a
warning on the flask_vouch.netset logger; ns.loaded reports the state.
Fetches time out after 30 seconds.
Redis backend
Challenges, rate limits and CAPTCHA datasets live in memory per process. For multi-process / multi-worker deployments move them to Redis, which also shares the secret and lets policy changes propagate to every worker:
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)
The first worker to start seeds the shared policy; later ones adopt what is
already stored, so restarting a worker cannot wipe a ruleset set with
update_rules(). update_policy and update_rules push and notify every worker.
from flask_vouch.redis import RedisNetSet
ns = RedisNetSet(r)
ns.load()
ns.start_updates() # one worker refreshes under a lock
vouch = Vouch(app, engine=engine, blocklist=ns)
Needs Redis 6.2 or newer: a solved challenge is redeemed with GETDEL, so one
solution mints exactly one cookie no matter how many workers race for it.
Production checklist
- Set a
secretof at least 16 bytes and keep it stable across restarts and workers, a new secret invalidates every issued cookie. - Set
trusted_proxieswhen running behind a proxy, otherwise per-IP limits are measured against the proxy address. - Use the Redis backend for more than one worker so challenges and rate limits are shared rather than per process.
- Serve over HTTPS,
cookie_secureonly sets theSecureflag on secure requests. vouch.exempt("static")keeps assets out of the bouncer, and health checks out of it withexclude=[r"^/health"].- Challenge pages need inline scripts, so keep the shipped
Content-Security-Policy(the response carries its own) intact at the proxy. - Watch the
flask_vouchlogger: it reports rate-limit hits and unverified bot claims atINFO, handler failures with a traceback. - Challenge and refusal responses carry
X-Robots-Tag: noindex, nofollowandReferrer-Policy: no-referrer; rate-limited ones carryRetry-After. Do not strip them at the proxy or challenge pages end up in search results. verify_botsdoes a reverse DNS lookup on the first request from each crawler address, cached for an hour. Setverify_bots=Falsewhere the resolver is slow or unavailable.
Package layout
| Module | Contents |
|---|---|
vouch.py |
Flask glue: Vouch, decorators, request/response mapping |
engine.py |
Engine: challenges, cookies, rate-limit checks |
policy.py |
Request, Rule, Policy, load_policy, defaults |
rendering.py |
Template resolution, challenge page rendering, CSP headers |
stores.py |
In-memory ChallengeStore, RateLimiter, Metrics |
tokens.py |
HS256 JWT encode/decode |
crawlers.py |
Crawler detection and reverse-DNS bot verification |
netset.py |
NetSet IP blocklists |
redis.py |
Redis-backed store, rate limiter, netset and engine |
challenges/ |
Challenge handlers, their pages and datasets |
challenges/motion.py |
Motion attestation scoring and MotionAttestation |
extras/ |
ErrorHandler, RateLimiter, ThirdPartyCaptcha |
Extras
ErrorHandler
from flask_vouch.extras import ErrorHandler
eh = ErrorHandler(vouch=vouch)
eh.init_flask(app)
Run it after Vouch.init_app: init_flask then also styles Vouch's own 403,
429 and 503 refusals, which would otherwise be plain text. Values passed to
render() are HTML-escaped and substituted in one pass.
RateLimiter
from flask_vouch.extras import RateLimiter
rl = RateLimiter(default="100/minute", trusted_proxies=1)
rl.exempt("static")
rl.init_flask(app)
@app.route("/login")
@rl.limit("5/minute")
def login(): ...
init_flask buckets per endpoint, so every asset on a page shares one static
budget, exempt it unless you want asset-heavy browsing to hit 429. A route with
its own @limit keeps only that budget rather than being counted twice.
trusted_proxies works exactly as it does on Vouch: without it
X-Forwarded-For is ignored, since a client that can set the header freely
would get a new bucket per request. Behind a proxy you must set it, or everyone
shares the proxy's single budget. Retry-After reports the real window.
ThirdPartyCaptcha
Puts an external CAPTCHA on your own forms, separate from the bouncer. Pass the
keys per provider, init_flask then exposes each widget to Jinja:
from flask_vouch.extras import ThirdPartyCaptcha
tpc = ThirdPartyCaptcha(
turnstile_site_key="...", turnstile_secret="...",
language="en", # "auto" follows the browser
theme="dark", # "auto" follows the color scheme
)
tpc.init_flask(app)
@app.route("/submit", methods=["POST"])
def submit():
if not tpc.is_turnstile_valid():
abort(403)
...
Drop a widget into a template by name, it renders as HTML and brings its own hidden field:
<form method="post" action="/submit">
<input name="email" type="email" required />
{{ turnstile }}
<button type="submit">Sign up</button>
</form>
Every provider takes <name>_site_key and <name>_secret and is checked with
is_<name>_valid(): recaptcha, hcaptcha, turnstile, friendly,
captchafox, mtcaptcha, arkose, geetest. Only the ones you passed keys for
appear; a validator returns False when the token is missing, stale or the
secret is unset.
Altcha is self-hosted proof of work, so it has no site key and altcha_secret is
optional, init_flask derives one from SECRET_KEY (or the Vouch secret). It
comes in five difficulties, {{ altcha1 }} to {{ altcha5 }}, with
{{ altcha }} at level 2. Its challenges expire after ten minutes and each one
is redeemable once; that memory is per process, so across several workers a
solution stays replayable on the others until it expires.
Outside Jinja:
embed = tpc.get_embed("turnstile")
embed = tpc.get_embed("altcha", hardness=4)
embed = tpc.get_embed("recaptcha", site_key="...")
Development
pip install -e ".[image,audio]" pytest pytest-cov black isort basedpyright redis
pytest # suite runs on Python 3.9 - 3.14 in CI
basedpyright # type check, targets the oldest supported version
isort . && black .
npx prtfm
The Redis tests want a server on port 6399 and skip themselves without one:
redis-server --port 6399 --save '' --daemonize yes
License
Metadata
Release files for flask-Vouch 1.4.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| flask_vouch-1.4.1.tar.gz | 7.7 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| flask_vouch-1.4.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 15.7 MB
Release files / flask_vouch-1.4.1.tar.gz
| Download URL | flask_vouch-1.4.1.tar.gz |
|---|---|
| Size | 7.7 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
29be05c59eb7aa58859b7617714c2689ea68bd29a6034badde8a539c965cecca
|
|
BLAKE2b-256 checksum How to use checksums |
1ca8df30a63c4e81df7d0aa7d8d4b0a99ce8bd5f821d8b7759927dfb97f32415
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 12, 2026.
Transparency logRelease files / flask_vouch-1.4.1-py3-none-any.whl
| Download URL | flask_vouch-1.4.1-py3-none-any.whl |
|---|---|
| Size | 7.9 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0e420a8b37f62b479c9bc1fda5040ed440bb036613d82d7f93cc59a2a4e7637a
|
|
BLAKE2b-256 checksum How to use checksums |
4d519b4028defc9c134cae2cc3bbc7c6958b07ffdae2bda8e00d764862434f4a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 12, 2026.
Transparency log