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
    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 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
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

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

flask_vouch-1.4.0.tar.gz (7.7 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

flask_vouch-1.4.0-py3-none-any.whl (7.9 MB view details)

Uploaded Python 3

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

Hashes for flask_vouch-1.4.0.tar.gz
Algorithm Hash digest
SHA256 519f0fc6846b56d0f42a95fad8b8b93a04319b744025e558c42fbad06b400a90
MD5 f5bd2735e1bdd7911a2457a46c53c205
BLAKE2b-256 575bc9e2e454bad1f7625665de9600938f9b6853917384f20fe027d19ceec98d

See more details on using hashes here.

Provenance

The following attestation bundles were made for flask_vouch-1.4.0.tar.gz:

Publisher: publish.yml on tn3w/flask-Vouch

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for flask_vouch-1.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 10e96e66b06414fe3a2083bc662abcbfc4d5417392b88f896a4b911ca05d954f
MD5 843ad35d7b236576589bce5e6b8c4d68
BLAKE2b-256 6279d17b5ea80cfdd4d683f6c10e3de9e965885fdc454509d03672a4e6433eea

See more details on using hashes here.

Provenance

The following attestation bundles were made for flask_vouch-1.4.0-py3-none-any.whl:

Publisher: publish.yml on tn3w/flask-Vouch

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

1.4.0 This release

2 files

1.3.0

2 files

1.2.8

2 files

1.2.7

2 files

1.2.6

2 files

1.2.5

2 files

1.2.4

2 files

1.2.3

2 files

1.2.2

2 files

1.2.1

2 files

1.2.0

2 files

1.1.1

2 files

1.1.0

2 files

1.0.4

2 files

1.0.3

2 files

1.0.2

2 files

1.0.1

2 files

1.0.0

2 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