Skip to main content

𐌅𐌋𐌀𐌔𐌊-ᕓꝊ𐌵𐌂𐋅

Bot-challenge middleware for Flask. Intercepts unrecognized visitors, issues proof-of-work or CAPTCHA challenges, and grants HMAC-signed JWT access cookies to solvers.

PyPI Python License Issues Stars Downloads

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

  1. Every suspicious unauthenticated request matching the configured rules is redirected to a challenge page.
  2. A proof-of-work challenge (SHA-256 Balloon by default) is issued.
  3. The browser solves it in JavaScript and POSTs to /.vouch/verify.
  4. 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 secret of at least 16 bytes and keep it stable across restarts and workers, a new secret invalidates every issued cookie.
  • Set trusted_proxies when 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_secure only sets the Secure flag on secure requests.
  • vouch.exempt("static") keeps assets out of the bouncer, and health checks out of it with exclude=[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_vouch logger: it reports rate-limit hits and unverified bot claims at INFO, handler failures with a traceback.
  • Challenge and refusal responses carry X-Robots-Tag: noindex, nofollow and Referrer-Policy: no-referrer; rate-limited ones carry Retry-After. Do not strip them at the proxy or challenge pages end up in search results.
  • verify_bots does a reverse DNS lookup on the first request from each crawler address, cached for an hour. Set verify_bots=False where 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

Apache-2.0

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)

Source distribution for flask-Vouch 1.4.1
File Size Uploaded
flask_vouch-1.4.1.tar.gz 7.7 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for flask-Vouch 1.4.1
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

1.4.1 This release

2 release files

1.4.0

2 release files

1.3.0

2 release files

1.2.8

2 release files

1.2.7

2 release files

1.2.6

2 release files

1.2.5

2 release files

1.2.4

2 release files

1.2.3

2 release files

1.2.2

2 release files

1.2.1

2 release files

1.2.0

2 release files

1.1.1

2 release files

1.1.0

2 release files

1.0.4

2 release files

1.0.3

2 release files

1.0.2

2 release files

1.0.1

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page