𐌅𐌋𐌀𐌔𐌊-ᕓꝊ𐌵𐌂𐋅
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
QuirkProbe, # browser-engine quirk verification
ThirdPartyCaptchaChallenge, # embed external CAPTCHAs
)
vouch = Vouch(app, secret="s", challenge_handler=CharacterCaptcha())
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:
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 |
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
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file flask_vouch-1.4.0.tar.gz.
File metadata
- Download URL: flask_vouch-1.4.0.tar.gz
- Upload date:
- Size: 7.7 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
519f0fc6846b56d0f42a95fad8b8b93a04319b744025e558c42fbad06b400a90
|
|
| MD5 |
f5bd2735e1bdd7911a2457a46c53c205
|
|
| BLAKE2b-256 |
575bc9e2e454bad1f7625665de9600938f9b6853917384f20fe027d19ceec98d
|
Provenance
The following attestation bundles were made for flask_vouch-1.4.0.tar.gz:
Publisher:
publish.yml on tn3w/flask-Vouch
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
flask_vouch-1.4.0.tar.gz -
Subject digest:
519f0fc6846b56d0f42a95fad8b8b93a04319b744025e558c42fbad06b400a90 - Sigstore transparency entry: 2802791092
- Sigstore integration time:
-
Permalink:
tn3w/flask-Vouch@a8308017de2510b75035d3f114c40e9599216974 -
Branch / Tag:
refs/heads/master - Owner: https://github.com/tn3w
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a8308017de2510b75035d3f114c40e9599216974 -
Trigger Event:
push
-
Statement type:
File details
Details for the file flask_vouch-1.4.0-py3-none-any.whl.
File metadata
- Download URL: flask_vouch-1.4.0-py3-none-any.whl
- Upload date:
- Size: 7.9 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
10e96e66b06414fe3a2083bc662abcbfc4d5417392b88f896a4b911ca05d954f
|
|
| MD5 |
843ad35d7b236576589bce5e6b8c4d68
|
|
| BLAKE2b-256 |
6279d17b5ea80cfdd4d683f6c10e3de9e965885fdc454509d03672a4e6433eea
|
Provenance
The following attestation bundles were made for flask_vouch-1.4.0-py3-none-any.whl:
Publisher:
publish.yml on tn3w/flask-Vouch
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
flask_vouch-1.4.0-py3-none-any.whl -
Subject digest:
10e96e66b06414fe3a2083bc662abcbfc4d5417392b88f896a4b911ca05d954f - Sigstore transparency entry: 2802791129
- Sigstore integration time:
-
Permalink:
tn3w/flask-Vouch@a8308017de2510b75035d3f114c40e9599216974 -
Branch / Tag:
refs/heads/master - Owner: https://github.com/tn3w
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a8308017de2510b75035d3f114c40e9599216974 -
Trigger Event:
push
-
Statement type: