A bot-challenge Python middleware issuing brief challenges, granting solvers signed access cookies.
Project description
tollbooth
A bot-challenge Python middleware issuing brief challenges, granting solvers signed access cookies.
from flask import Flask
from tollbooth.integrations.flask import Tollbooth
app = Flask(__name__)
app.config["SECRET_KEY"] = "your-secret-key"
tb = Tollbooth(app) # uses SECRET_KEY automatically
Bots get a browser challenge page. Humans solve it once, get a cookie, browse freely.
Screenshots
from tollbooth import CharacterCaptcha, TollboothWSGI
app = TollboothWSGI(app, secret="key", challenge_handler=CharacterCaptcha())
CharacterCaptcha() Solution: U5R6H3
SHA256 / SHA256Balloon() Default
SlidingCaptcha()
CircleCaptcha()
Contents
- Screenshots
- Examples
- Install
- How it works
- Usage
- Challenge types
- Configuration
- Rules
- Integrations
- Reading claims
- IP Blocklist
- Redis
- Third-party CAPTCHAs
- Standalone Rate Limiter
- Tests
- Contributing
- Security
- Managing screenshots
- License
Examples
Runnable examples for every integration and challenge type:
examples/
general.py # WSGI + ASGI quickstart (python examples/general.py [wsgi|asgi])
integrations/
wsgi.py # bare WSGI
asgi.py # bare ASGI (requires uvicorn)
flask_app.py # Flask middleware + per-route + exempt
fastapi_app.py # FastAPI middleware + dependency + verify
starlette_app.py # Starlette middleware
django_app.py # Django — self-contained, runs with runserver
falcon_app.py # Falcon middleware + per-resource hook
challenges/
sha256_balloon.py # SHA256Balloon (default, memory-hard)
sha256.py # SHA256 (faster, no memory cost)
character_captcha.py # Character CAPTCHA (requires Pillow)
sliding_captcha.py # Sliding puzzle CAPTCHA (requires Pillow)
circle_captcha.py # Click-the-incomplete-circle CAPTCHA (requires Pillow)
image_captcha.py # Image selection CAPTCHA (requires Pillow)
image_grid_captcha.py # Image grid CAPTCHA (requires Pillow)
audio_captcha.py # Audio CAPTCHA (requires numpy, scipy)
navigator_attestation.py # Browser fingerprinting
third_party_captcha.py # Third-party CAPTCHA (pass provider as first arg)
Install
pip install tollbooth
pip install tollbooth[flask] # Flask
pip install tollbooth[django] # Django
pip install tollbooth[fastapi] # FastAPI
pip install tollbooth[falcon] # Falcon
pip install tollbooth[starlette] # Starlette
pip install tollbooth[redis] # Redis backend
pip install tollbooth[image] # Character / Sliding / Image / Image Grid CAPTCHA (Pillow)
pip install tollbooth[audio] # Audio CAPTCHA (numpy, scipy)
How it works
Browser Server
│ │
│ GET /protected │
│─────────────────────────────────►│ ◄── rule engine evaluates
│ │ User-Agent, path, IP…
│◄─────────────────────────────────│
│ 429 ┌──────────────────────┐ │
│ │ Checking your │ │
│ │ browser… 72 H/s │ │
│ └──────────────────────┘ │
│ │
│ Web Workers compute │
│ Balloon(random_data+nonce) │
│ until hash has ≥N leading │
│ zero bits │
│ │
│ POST /.tollbooth/verify │
│ { id, nonce, redirect } │
│─────────────────────────────────►│ ◄── server re-runs hash,
│ │ checks leading zeros
│◄─────────────────────────────────│
│ 302 Set-Cookie: _tollbooth=JWT │
│ │
│ GET /protected Cookie: …JWT │
│─────────────────────────────────►│ ◄── JWT valid + IP matches
│ 200 OK │ → pass through
│◄─────────────────────────────────│
The cookie is HMAC-SHA256 signed, bound to the client IP hash, and valid for 7 days by default. Solved challenges are marked spent — nonces cannot be reused.
Rule evaluation
incoming request
│
▼
┌─────────────────────────────────────┐
│ rule 1: action=allow path=/health │──── matches? ──► ALLOW (pass through)
├─────────────────────────────────────┤
│ rule 2: action=deny ua=sqlmap │──── matches? ──► DENY (403)
├─────────────────────────────────────┤
│ rule 3: action=challenge ua=GPTBot │──── matches? ──► CHALLENGE
├─────────────────────────────────────┤
│ rule 4: action=weigh +3 no UA │──── matches? ──► weight += 3
├─────────────────────────────────────┤
│ rule 5: action=weigh +2 no lang │──── matches? ──► weight += 2
└─────────────────────────────────────┘
│
▼
weight ≥ threshold? ──► CHALLENGE
│
▼
ALLOW
Usage
Flask
Uses Flask's SECRET_KEY by default — no separate secret needed. All engine/policy values can be set via app.config with the TOLLBOOTH_ prefix (TOLLBOOTH_DEFAULT_DIFFICULTY, TOLLBOOTH_COOKIE_TTL, etc.). Constructor kwargs override config values.
from flask import Flask
from tollbooth.integrations.flask import Tollbooth
app = Flask(__name__)
app.config["SECRET_KEY"] = "your-secret-key"
tb = Tollbooth(app)
@app.route("/")
def index():
return "Hello!"
@tb.exempt
@app.route("/health")
def health():
return "ok"
Config-driven setup (factory pattern):
app = Flask(__name__)
app.config["SECRET_KEY"] = "your-secret-key"
app.config["TOLLBOOTH_DEFAULT_DIFFICULTY"] = 14
app.config["TOLLBOOTH_BRANDING"] = False
app.config["TOLLBOOTH_ACCENT_COLOR"] = "#ff4488"
tb = Tollbooth()
tb.init_app(app)
Explicit secret still works and takes priority:
tb = Tollbooth(app, secret="override-secret", default_difficulty=12)
Django
Uses Django's SECRET_KEY by default — no separate secret needed. Override with TOLLBOOTH = {"secret": "..."} if desired.
# settings.py
SECRET_KEY = "your-secret-key"
TOLLBOOTH = {"default_difficulty": 14} # secret falls back to SECRET_KEY
MIDDLEWARE = [
"tollbooth.integrations.django.TollboothMiddleware",
# ...
]
# views.py
from tollbooth.integrations.django import tollbooth_exempt, tollbooth_protect
@tollbooth_exempt
def health(request):
return HttpResponse("ok")
@tollbooth_protect(difficulty=14)
def api_data(request):
return JsonResponse({"rows": [...]})
FastAPI
from fastapi import FastAPI, Depends
from tollbooth.integrations.fastapi import TollboothMiddleware, TollboothDep
# Protect all routes (JSON mode on by default)
app = FastAPI()
app.add_middleware(TollboothMiddleware, secret="your-secret-key")
Route-level protection — only challenged routes pay the PoW cost:
protect = TollboothDep("your-secret-key")
@app.get("/public")
def public():
return {"open": True}
@app.get("/protected", dependencies=[Depends(protect)])
def protected():
return {"secret": True}
Starlette
from starlette.applications import Starlette
from tollbooth.integrations.starlette import TollboothMiddleware
app = Starlette(routes=[...])
app = TollboothMiddleware(app, secret="your-secret-key")
Falcon
import falcon
from tollbooth.integrations.falcon import TollboothMiddleware, tollbooth_hook
app = falcon.App(middleware=[TollboothMiddleware(secret="your-secret-key")])
Per-resource with a hook:
from tollbooth.integrations.falcon import tollbooth_hook
hook = tollbooth_hook("your-secret-key", difficulty=14)
class SensitiveResource:
@falcon.before(hook)
def on_get(self, req, resp):
resp.media = {"rows": [...]}
Raw WSGI / ASGI
from tollbooth import TollboothWSGI, TollboothASGI
app = TollboothWSGI(wsgi_app, secret="your-secret-key") # Flask, Django, …
app = TollboothASGI(asgi_app, secret="your-secret-key") # FastAPI, Starlette, …
Challenge types
Difficulty is expressed in SHA256-Balloon units — each type applies its own offset so equal numbers mean equal expected work.
difficulty=10 (policy setting)
│
├── SHA256Balloon offset +0 → effective 10 ~1 024 hashes × 32 KB/hash
├── SHA256 offset +6 → effective 16 ~65 536 hashes (no memory cost)
├── CharacterCaptcha offset -4 → effective 6 6-character solution
├── SlidingCaptcha offset -4 → effective 6 sliding puzzle
├── CircleCaptcha offset -4 → effective 6 click the incomplete circle
├── ImageCaptcha offset -4 → effective 6 pick matching image from 6
├── ImageGridCaptcha offset -4 → effective 6 select matching images in 3×3 grid
└── AudioCaptcha offset -4 → effective 6 type characters from audio
| Type | Class | Offset | Solved by | GPU-resistant |
|---|---|---|---|---|
sha256-balloon |
SHA256Balloon |
+0 | browser JS | ✓ |
sha256 |
SHA256 |
+6 | browser JS | ✗ |
character-captcha |
CharacterCaptcha |
-4 | human | ✓ |
sliding-captcha |
SlidingCaptcha |
-4 | human | ✓ |
circle-captcha |
CircleCaptcha |
-4 | human | ✓ |
image-captcha |
ImageCaptcha |
-4 | human | ✓ |
image-grid-captcha |
ImageGridCaptcha |
-4 | human | ✓ |
audio-captcha |
AudioCaptcha |
-4 | human | ✓ |
navigator-attestation |
NavigatorAttestation |
+0 | browser (WS) | ✓ |
SHA256Balloon & SHA256
Both are browser proof-of-work challenges solved automatically by JavaScript. SHA256Balloon is memory-hard (GPU-resistant); SHA256 has no memory requirement but applies offset +6 to compensate. Use SHA256Balloon by default; use SHA256 when client environment is constrained or solve speed matters more than GPU resistance.
Tuning
from tollbooth import SHA256Balloon, SHA256, TollboothWSGI
# Default — memory-hard, GPU-resistant (32 KB per attempt)
app = TollboothWSGI(app, secret="key")
# Heavier — 64 KB, harder to parallelize
app = TollboothWSGI(app, secret="key",
challenge_handler=SHA256Balloon(space_cost=2048))
# Plain SHA256 — faster, no memory cost, higher difficulty to compensate
app = TollboothWSGI(app, secret="key",
challenge_handler=SHA256(), default_difficulty=14)
Navigator Attestation
Passive browser fingerprinting challenge — no user interaction required. The challenge page opens a WebSocket to /.tollbooth/attest, runs 3 rounds of signal collection (browser APIs, automation markers, hardware consistency, rendering fingerprints, and 20+ other categories), then scores the result server-side.
from tollbooth import NavigatorAttestation, TollboothASGI
app = TollboothASGI(
asgi_app,
secret="your-secret-key",
challenge_handler=NavigatorAttestation(),
)
Difficulty controls the minimum score threshold required to pass (higher = stricter):
difficulty=5 → threshold 0.50 (suspicious browsers pass)
difficulty=10 → threshold 0.60 (default)
difficulty=15 → threshold 0.70
difficulty=20 → threshold 0.80
The signal validator is a full Python port of the navigator-attestation JS library. Scoring covers automation globals, Selenium/CDP artifacts, stealth plugin markers, headless heuristics, VM detection, canvas/WebGL fingerprints, and cross-signal consistency checks. The attestation token is HMAC-signed with a per-challenge secret and expires after 5 minutes.
Reading the score
score (0.0–1.0) is available on the claims object after a visitor passes. It is None for PoW challenges (SHA256Balloon / SHA256 / CharacterCaptcha / SlidingCaptcha) — only NavigatorAttestation embeds it.
# Flask
score = g.tollbooth.score
# Django / FastAPI / Starlette / Falcon — see Reading claims
score = request.state.tollbooth.score
Character CAPTCHA
Human-solved visual challenge. Renders distorted alphanumeric characters over a background using system fonts, with random per-character rotation, color, and position, plus line and noise overlays. Solution length scales with difficulty (offset -4): difficulty 10 → 6 characters. Solution is HMAC-encrypted in the challenge store — never stored in plaintext. Works without JavaScript. Requires Pillow:
pip install tollbooth[image]
Setup
from tollbooth import CharacterCaptcha, TollboothWSGI
app = TollboothWSGI(
app,
secret="your-secret-key", # also used to sign CAPTCHA solution tokens
challenge_handler=CharacterCaptcha(
backgrounds_path="/path/to/backgrounds", # optional directory of .jpg files
token_ttl=1800, # solution token lifetime in seconds
),
)
System fonts are detected automatically from common OS directories. Only fonts from known Latin families (DejaVu, Fira, Liberation, Ubuntu, etc.) are used. Background images are optional — falls back to a solid fill.
Sliding CAPTCHA
Human-solved drag-and-drop challenge. Renders a decorative background (wavy lines, concentric circles, 3D wireframe shapes, noise) then cuts out a rectangular puzzle piece, leaving a dark outlined hole. The user slides the piece to fill the hole using an <input type="range">. The correct position is HMAC-encrypted in the challenge token — never stored in plaintext. Verification accepts ±15 px tolerance (tightens by 1 px per difficulty level, minimum 5 px). Requires Pillow:
pip install tollbooth[image]
Setup
from tollbooth import SlidingCaptcha, TollboothWSGI
app = TollboothWSGI(
app,
secret="your-secret-key",
challenge_handler=SlidingCaptcha(
token_ttl=1800, # solution token lifetime in seconds
),
)
Circle CAPTCHA
Human-solved click challenge. Renders an image containing several complete circles and one with a visible gap in its arc. The user clicks the incomplete circle — click coordinates are captured and submitted automatically. The correct circle's center and radius are HMAC-encrypted in the challenge token. Verification accepts clicks within radius + 15 px of the target circle's center. Difficulty controls the number of circles (max(3, 2 + d//2)) and gap arc size (max(25°, 50° - 2d)). Requires Pillow:
pip install tollbooth[image]
Setup
from tollbooth import CircleCaptcha, TollboothWSGI
app = TollboothWSGI(
app,
secret="your-secret-key",
challenge_handler=CircleCaptcha(
token_ttl=1800,
),
)
Image CAPTCHA
Human-solved image selection challenge. Shows a preview image at the top and 6 candidate images below — exactly one matches the preview. Images are downloaded from community datasets (pickle format) and distorted with grid overlays, noise, pixel shifting, and color adjustments scaled by difficulty. No JavaScript required. Requires Pillow:
pip install tollbooth[image]
Setup
from tollbooth import ImageCaptcha, TollboothWSGI
app = TollboothWSGI(
app,
secret="your-secret-key",
challenge_handler=ImageCaptcha(
dataset="ai_dogs", # "ai_dogs", "animals", or "keys"
token_ttl=1800,
),
)
Image Grid CAPTCHA
Human-solved multi-select challenge. Displays a 3×3 grid of images and a text prompt naming a category. The user selects all images matching the category (typically 2–4). Images are distorted the same way as ImageCaptcha. No JavaScript required. Requires Pillow:
pip install tollbooth[image]
Setup
from tollbooth import ImageGridCaptcha, TollboothWSGI
app = TollboothWSGI(
app,
secret="your-secret-key",
challenge_handler=ImageGridCaptcha(
dataset="ai_dogs", # "ai_dogs", "animals", or "keys"
token_ttl=1800,
),
)
Audio CAPTCHA
Human-solved audio challenge. Plays audio clips of individual characters concatenated with random silence gaps and background noise. The user types the characters they hear. Character count scales with difficulty (offset -4): difficulty 10 → 6 characters. Audio is distorted with background noise, random beeps, and speed variations scaled by difficulty. No JavaScript required. Requires numpy and scipy:
pip install tollbooth[audio]
Setup
from tollbooth import AudioCaptcha, TollboothWSGI
app = TollboothWSGI(
app,
secret="your-secret-key",
challenge_handler=AudioCaptcha(
dataset="characters", # audio dataset name
lang="en", # language for character audio
token_ttl=1800,
),
)
Third-party CAPTCHA challenge
ThirdPartyCaptchaChallenge integrates any supported third-party CAPTCHA provider directly into tollbooth's own challenge engine. The user is redirected to a tollbooth-hosted page that renders the CAPTCHA widget; on completion the widget token is submitted and verified server-side before issuing the access cookie.
Supported providers: recaptcha, hcaptcha, turnstile, friendly, captchafox, mtcaptcha, arkose, geetest, altcha
The page retries on failure (retry_on_failure = True). Difficulty does not affect the external verification logic; the offset is 0.
Setup
from tollbooth import ThirdPartyCaptchaChallenge, CaptchaCreds, AltchaCreds, TollboothWSGI
# Standard provider (reCAPTCHA, hCaptcha, Turnstile, …)
app = TollboothWSGI(
app,
secret="your-secret-key",
challenge_handler=ThirdPartyCaptchaChallenge(
provider="recaptcha",
creds=CaptchaCreds(
site_key="6Le...",
secret_key="6Le...",
),
language="en", # optional, default "auto"
theme="auto", # "light" | "dark" | "auto"
),
)
# GeeTest v4
challenge_handler=ThirdPartyCaptchaChallenge(
provider="geetest",
creds=CaptchaCreds(site_key="...", secret_key="..."),
)
# Altcha (self-hosted, no site key)
challenge_handler=ThirdPartyCaptchaChallenge(
provider="altcha",
creds=AltchaCreds(secret_key="your-altcha-secret"),
)
The credential type aliases ReCaptchaCreds, HCaptchaCreds, TurnstileCreds, FriendlyCaptchaCreds, CaptchaFoxCreds, MTCaptchaCreds, ArkoseCreds, GeeTestCreds are all aliases for CaptchaCreds.
Difficulty reference
Expected hashes to solve (2^difficulty). Each +1 doubles solve time.
difficulty │ SHA256-Balloon │ SHA256
────────────┼──────────────────┼────────────────
8 │ 256 │ 16 384 (+6)
10 │ 1 024 │ 65 536
12 │ 4 096 │ 262 144
14 │ 16 384 │ 1 048 576
16 │ 65 536 │ 4 194 304
20 │ 1 048 576 │ 67 108 864
Typical browser solve time at difficulty 10 (SHA256-Balloon, 4 cores): ~0.5 s
Configuration
Flask and Django integrations pull secret from the framework's built-in secret key (SECRET_KEY). All options below can also be set via Flask's app.config["TOLLBOOTH_<UPPER_NAME>"] or Django's TOLLBOOTH = {...} dict.
from tollbooth import SHA256Balloon, TollboothWSGI
TollboothWSGI(
app,
secret="your-secret-key", # required for WSGI/ASGI/Falcon
default_difficulty=10, # baseline PoW difficulty (default: 10)
challenge_handler=SHA256Balloon( # algorithm + its parameters
space_cost=1024, # memory blocks per attempt (default: 1024)
time_cost=1, # mixing rounds (default: 1)
delta=3, # random lookups per step (default: 3)
),
challenge_threshold=5, # weight sum to trigger challenge (default: 5)
cookie_ttl=604800, # cookie lifetime in seconds (default: 7 days)
challenge_ttl=1800, # challenge expiry in seconds (default: 30 min)
branding=True, # "Protected by tollbooth" footer (default: True)
accent_color="#44ff88", # theme accent color (default: "#44ff88")
max_challenge_failures=3, # failed verifies before 429 lockout (default: 3)
max_challenge_requests=10, # challenge generations before 429 lockout (default: 10)
rate_limit_window=300, # sliding window for both limits in seconds (default: 300)
token_rate_limit=120, # max requests per token per rate window (default: 120)
token_rate_window=60, # rate window duration in seconds (default: 60)
token_total_limit=3000, # max lifetime requests per token (default: 3000)
exclude=[r"^/static/", r"^/_/"], # paths that bypass all checks
)
Rules
Rules are evaluated top-to-bottom. The first matching terminal action (allow, deny, challenge) wins. weigh rules accumulate a score — when it reaches challenge_threshold, a challenge is issued.
Rule fields
{
"name": "rule-name",
"action": "allow | deny | challenge | weigh",
"user_agent": "regex",
"path": "regex",
"headers": { "Header-Name": "value-regex" },
"remote_addresses": ["10.0.0.0/8", "2001:db8::/32"],
"difficulty": 14,
"weight": 3,
"blocklist": false,
"crawler": false,
"bogon_ip": false
}
All match fields are optional and ANDed together. A rule with no match fields matches everything. remote_addresses uses CIDR notation; all other string fields use regex.
blocklist: true— matches when the client IP is in a loadedIPBlocklist; silently skipped when no blocklist is loaded.crawler: true— matches known crawlers via thecrawleruseragentspackage; silently skipped when the package is not installed.bogon_ip: true— matches any IP that is not globally routable: private ranges (RFC 1918), loopback, link-local, multicast, reserved, unspecified, or unparseable. Useful to challenge requests that arrive with a spoofed or non-routable source IP.
Actions
| Action | Effect |
|---|---|
allow |
Pass through immediately, skip remaining rules |
deny |
Return 403 Forbidden |
challenge |
Issue PoW challenge at given difficulty |
weigh |
Add weight to running score, keep going |
Default rules
Tollbooth ships with rules.json covering common traffic patterns out of the box:
| Category | Examples |
|---|---|
| Deny | sqlmap, Acunetix, Nmap, .env/.git probes, shell probes, path traversal |
| Allow | Googlebot, Bingbot, UptimeRobot, Pingdom, Slack/Discord previews, archive.org |
| Challenge | AI crawlers (GPT/Claude/CCBot, diff=14), headless browsers (diff=12), Scrapy (diff=12), known crawlers, IP blocklist, bogon-origin |
| Weigh | curl/wget (+3), missing Accept (+3), missing Accept-Language (+2), Connection:close (+2) |
When you pass rules= to Engine or any integration, your rules are prepended to the defaults so they take priority. The built-in rules remain active unless you explicitly opt out:
# custom rule runs first; default rules still apply after
app = TollboothWSGI(your_app, secret="key", rules=[
Rule(name="internal", action="allow", remote_addresses=["10.0.0.0/8"]),
])
# disable all default rules — only your rules run
app = TollboothWSGI(your_app, secret="key", default_rules=False, rules=[
Rule(name="internal", action="allow", remote_addresses=["10.0.0.0/8"]),
Rule(name="default", action="challenge"),
])
To replace the default rules entirely with a file, use rules_file=:
app = TollboothWSGI(your_app, secret="key", rules_file="/etc/tollbooth/rules.json")
Custom policy examples
Allow internal network, challenge everything else:
from tollbooth import Policy, Rule, TollboothWSGI
policy = Policy(rules=[
Rule(name="internal", action="allow", remote_addresses=["10.0.0.0/8"]),
Rule(name="health", action="allow", path=r"^/health$"),
Rule(name="default", action="challenge"),
])
app = TollboothWSGI(your_app, secret="key", policy=policy)
Tiered difficulty — harder challenge for scrapers, lighter for everyone else:
policy = Policy(
default_difficulty=8,
rules=[
Rule(name="scrapers", action="challenge", difficulty=16,
user_agent=r"(?i:python-requests|scrapy|curl)"),
Rule(name="default", action="challenge"),
],
)
Block AI bots entirely, challenge everything else:
[
{
"name": "ai-deny",
"action": "deny",
"user_agent": "(?i:GPTBot|ChatGPT|Claude-Web|CCBot|Bytespider|Diffbot)"
},
{ "name": "default", "action": "challenge" }
]
Weight-based scoring — no single rule triggers a challenge, but combinations do:
[
{ "name": "curl", "action": "weigh", "weight": 3, "user_agent": "(?i:^curl/|^Wget/)" },
{ "name": "no-ua", "action": "weigh", "weight": 3, "user_agent": "^$" },
{ "name": "no-lang", "action": "weigh", "weight": 2, "headers": { "Accept-Language": "^$" } },
{ "name": "no-accept", "action": "weigh", "weight": 2, "headers": { "Accept": "^$" } }
]
With challenge_threshold=5: curl alone (3) passes, no-UA (3) passes, but curl + no-lang (5) triggers a challenge.
Path-specific rules — protect /admin hard, leave everything else on defaults:
[
{
"name": "admin-block",
"action": "deny",
"path": "^/admin",
"user_agent": "(?i:bot|spider|scraper)"
},
{ "name": "admin-challenge", "action": "challenge", "path": "^/admin", "difficulty": 16 }
]
Integrations
All framework integrations accept the same keyword arguments as TollboothWSGI/TollboothASGI. Flask and Django use the framework's SECRET_KEY by default — no separate secret needed.
| Integration | Import | Middleware class | Per-route | Exempt | Auto secret |
|---|---|---|---|---|---|
| Flask | tollbooth.integrations.flask |
Tollbooth(app) |
@tb.protect |
@tb.exempt |
SECRET_KEY |
| Django | tollbooth.integrations.django |
TollboothMiddleware |
@tollbooth_protect |
@tollbooth_exempt |
SECRET_KEY |
| FastAPI | tollbooth.integrations.fastapi |
TollboothMiddleware |
TollboothDep |
exclude=[...] |
— |
| Falcon | tollbooth.integrations.falcon |
TollboothMiddleware |
tollbooth_hook |
exclude=[...] |
— |
| Starlette | tollbooth.integrations.starlette |
TollboothMiddleware |
— | exclude=[...] |
— |
| WSGI | tollbooth |
TollboothWSGI |
— | — | — |
| ASGI | tollbooth |
TollboothASGI |
— | — | — |
Reusing an engine across integrations
from tollbooth import Engine, Policy, Rule
from tollbooth.integrations.flask import Tollbooth
engine = Engine(
secret="your-secret-key",
policy=Policy(rules=[Rule(name="all", action="challenge")]),
)
# Pass the same engine to multiple integrations
tb_flask = Tollbooth(flask_app, engine=engine)
tb_asgi = TollboothASGI(asgi_app, engine=engine)
JSON mode
For API/SPA backends where browsers aren't involved, json_mode=True returns structured JSON instead of an HTML challenge page:
# FastAPI uses json_mode=True by default
app.add_middleware(TollboothMiddleware, secret="key")
# Enable for all routes on any integration
TollboothBase(secret="key", json_mode=True)
# Enable only for /api/* routes
TollboothBase(secret="key", json_mode=lambda req: req["path"].startswith("/api/"))
SHA256-Balloon challenge response:
{
"challenge": {
"id": "Xk9mP2...",
"data": "a3f1...",
"difficulty": 10,
"spaceCost": 1024,
"timeCost": 1,
"delta": 3,
"verifyPath": "/.tollbooth/verify",
"redirect": "/api/data"
}
}
SHA256 challenge response (no memory parameters):
{
"challenge": {
"id": "Xk9mP2...",
"data": "a3f1...",
"difficulty": 16,
"verifyPath": "/.tollbooth/verify",
"redirect": "/api/data"
}
}
Verify by POSTing the solved nonce:
POST /.tollbooth/verify
Content-Type: application/x-www-form-urlencoded
id=Xk9mP2...&nonce=38471&redirect=/api/data
Response on success: 302 Location: /api/data Set-Cookie: _tollbooth=<JWT>
Reading claims
When a request passes (valid cookie, allow rule, or after solving a challenge), each integration exposes a claims object on the native request object — no manual cookie decoding needed.
| Integration | Claims location |
|---|---|
| Flask | flask.g.tollbooth |
| Django | request.tollbooth |
| FastAPI | request.state.tollbooth |
| Starlette | request.state.tollbooth |
| Falcon | req.context.tollbooth |
| WSGI | environ["tollbooth.claims"] |
| ASGI | scope["state"].tollbooth |
| Field | Type | Description |
|---|---|---|
score |
float | None |
Attestation score 0.0–1.0; None for PoW challenges |
iat |
int |
Issued-at timestamp |
exp |
int |
Expiry timestamp |
ip |
str |
HMAC of the client IP |
cid |
str |
Challenge ID |
matched_rule |
str | None |
Name of the deny or challenge rule that matched; None for allow / weight-based |
blocklist_match |
str | None |
IP range from the blocklist (e.g. "1.2.0.0/16") when a blocklist rule matched |
is_crawler |
bool |
True if the user-agent is a known crawler (requires crawleruseragents) |
crawler_name |
str | None |
Crawler product name when is_crawler is True |
# Flask
claims = g.tollbooth
# Django
claims = request.tollbooth
# FastAPI / Starlette
claims = request.state.tollbooth
# Falcon
claims = req.context.tollbooth
# WSGI
claims = environ["tollbooth.claims"]
print(claims.matched_rule, claims.is_crawler, claims.crawler_name)
is_crawler and crawler_name require the optional crawleruseragents package:
pip install crawleruseragents
IP Blocklist
Challenge or block known malicious IPs using tn3w/IPBlocklist. Supports single IPs, CIDR ranges, and IP ranges for IPv4 and IPv6.
In-memory
from tollbooth import Engine, IPBlocklist
# Single source — cached at ~/.cache/tollbooth/<filename>
blocklist = IPBlocklist() # defaults to tn3w/IPBlocklist
blocklist.load() # downloads once; uses cache on subsequent calls
blocklist.load(force=True) # bypass cache and re-download
# Multiple sources
blocklists = IPBlocklist.from_sources([
"https://example.com/list1.txt",
"https://example.com/list2.txt",
])
for bl in blocklists:
bl.load()
engine = Engine("your-secret-key", blocklist=blocklist) # or blocklist=blocklists
blocklist.start_updates(interval=86400) # daily refresh; clears cache before re-downloading
Parsed into compact integer ranges with O(log n) binary search. No dependencies.
The blocklist kwarg accepts a single IPBlocklist or a list[IPBlocklist]; an IP is blocked if it matches any.
Redis-backed
For multi-process deployments — one download, shared across all workers:
import redis
from tollbooth.redis import RedisEngine, RedisIPBlocklist
client = redis.Redis()
blocklist = RedisIPBlocklist(client)
blocklist.load() # download once, store in Redis sorted sets
engine = RedisEngine(client, secret="key", blocklist=blocklist)
blocklist.start_updates(interval=86400) # distributed lock — only one process downloads
Lookups use a server-side Lua script: one roundtrip, O(log n) via ZREVRANGEBYLEX.
Blocklist rules
{ "name": "known-bad", "action": "challenge", "blocklist": true }
{ "name": "known-bad", "action": "deny", "blocklist": true }
Rules with "blocklist": true are silently skipped when no blocklist is loaded.
Redis
Share challenges, secret, config, rules, rate-limit counters, and captcha datasets across workers via Redis (or Dragonfly, KeyDB, Valkey).
worker 1 worker 2 worker 3
│ │ │
└───────────────┴───────────────┘
│
┌─────▼──────┐
│ Redis │
│ challenges │
│ datasets │
│ secret │
│ config │
│ rate limits│
└────────────┘
pip install tollbooth[redis]
import redis
from tollbooth.redis import RedisEngine
client = redis.Redis(host="127.0.0.1", port=6379)
# First instance writes secret + config
engine = RedisEngine(client, secret="your-secret-key")
# Additional instances read from Redis — no secret needed
engine = RedisEngine(client)
Use with any integration:
from tollbooth.integrations.fastapi import TollboothMiddleware
from tollbooth.redis import RedisEngine
engine = RedisEngine(client, secret="your-secret-key")
app.add_middleware(TollboothMiddleware, engine=engine)
Live config updates propagate to all workers via pub/sub:
engine.update_secret("new-secret")
engine.update_policy(default_difficulty=14)
engine.update_rules([Rule(name="block", action="deny", path="^/admin")])
# Manual sync if auto_sync=False
engine.sync()
Namespace multiple deployments on one Redis instance:
RedisEngine(client, secret="key", prefix="myapp")
RedisEngine(client, secret="key", prefix="staging")
Shared Datasets
When using RedisEngine, image and audio captcha datasets are automatically stored in Redis instead of in-memory. This means datasets are downloaded once and shared across all workers, reducing memory usage per process. Random selection happens server-side via Lua scripts for atomicity.
To use a Redis-backed dataset store without RedisEngine:
from tollbooth.challenges.datasets import DatasetStore, set_default_store
store = DatasetStore(redis_client=client, prefix="tollbooth")
set_default_store(store)
Third-party CAPTCHAs
ThirdPartyCaptcha in tollbooth.extras.third_party_captcha embeds and validates third-party CAPTCHA providers across all supported frameworks. It is independent of tollbooth's own challenge engine.
To use a third-party CAPTCHA as a tollbooth challenge type instead (redirect-based, engine-managed), see ThirdPartyCaptchaChallenge.
Supported providers: recaptcha, hcaptcha, turnstile, friendly, captchafox, mtcaptcha, arkose, geetest, altcha
from tollbooth.extras.third_party_captcha import ThirdPartyCaptcha
captcha = ThirdPartyCaptcha(
language="en",
theme="auto", # "light" | "dark" | "auto"
recaptcha_site_key="...",
recaptcha_secret="...",
hcaptcha_site_key="...",
hcaptcha_secret="...",
turnstile_site_key="...",
turnstile_secret="...",
friendly_site_key="...",
friendly_secret="...",
captchafox_site_key="...",
captchafox_secret="...",
mtcaptcha_site_key="...",
mtcaptcha_secret="...",
arkose_site_key="...",
arkose_secret="...",
geetest_site_key="...",
geetest_secret="...",
altcha_secret="...", # self-hosted, no site key needed
)
get_context() returns all configured providers as a dict of pre-escaped HTML strings. get_embed(provider) returns HTML for a single provider.
Flask
captcha.init_flask(app) # injects all providers into every template context
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST" and captcha.is_recaptcha_valid():
...
return render_template("login.html") # {{ recaptcha }} in template
Django
# settings.py
TEMPLATES[0]["OPTIONS"]["context_processors"].append(
captcha.as_django_context_processor()
)
# views.py
def login(request):
if request.method == "POST" and captcha.is_recaptcha_valid(request):
...
return render(request, "login.html") # {{ recaptcha }} in template
Falcon
class LoginResource:
def on_post(self, req, resp):
if not captcha.is_recaptcha_valid(req):
raise falcon.HTTPForbidden()
FastAPI / Starlette
from fastapi import Request
@app.post("/login")
async def login(request: Request):
if not await captcha.is_recaptcha_valid_async(request):
raise HTTPException(status_code=403)
For template rendering, pass captcha.get_context() directly to TemplateResponse:
return templates.TemplateResponse(
"login.html",
{"request": request, **captcha.get_context()},
)
Altcha (self-hosted)
Altcha generates and verifies challenges server-side — no third-party account needed:
captcha = ThirdPartyCaptcha(altcha_secret="your-secret")
# In templates: {{ altcha }}, {{ altcha2 }}, … {{ altcha5 }} (hardness 1–5)
# Validate:
captcha.is_altcha_valid() # Flask
captcha.is_altcha_valid(request) # Django / Falcon
await captcha.is_altcha_valid_async(request) # FastAPI / Starlette
GeeTest v4
GeeTest requires four hidden fields populated by the JS callback (geetest_lotNumber, geetest_captchaOutput, geetest_passToken, geetest_genTime). The embed handles this automatically; verification is HMAC-signed server-side.
Arkose Labs
The embed loads Arkose's enforcement script dynamically, using the site key as the script path segment. The completed token is written to a hidden fc-token field.
Error Handler
ErrorHandler in tollbooth.extras.error_handler renders themed HTML error pages for
30 HTTP error codes (400–505) across all supported frameworks. It works independently of
tollbooth's challenge engine and matches the built-in challenge page look (light/dark,
accent color).
from tollbooth.extras import ErrorHandler
eh = ErrorHandler()
Templates use {{key}} placeholders: {{status_code}}, {{title}}, {{description}},
{{ACCENT_COLOR}}, plus any extra kwargs passed to render(). Both the global template
and per-status templates accept a string or a Path.
from pathlib import Path
eh = ErrorHandler(
template=Path("templates/error.html"), # or inline string
templates={404: Path("templates/404.html")}, # per-status override
overrides={404: {"title": "Oops", "description": "We lost that page."}},
codes={400, 403, 404, 500}, # limit handled codes
accent_color="#ff6600", # explicit accent
tollbooth=tb, # inherit accent from Tollbooth
)
body = eh.render(404) # render manually
body = eh.render(404, path="/missing") # extra template vars
When used alongside a Tollbooth Flask integration, init_flask automatically inherits
the accent color from the registered Tollbooth instance — no extra configuration needed.
Flask
tb = Tollbooth(app) # registers accent color in app.extensions
eh = ErrorHandler()
eh.init_flask(app) # picks up accent color automatically
Django
# settings.py
ErrorPageMiddleware = eh.as_django_middleware()
MIDDLEWARE = ["myapp.middleware.ErrorPageMiddleware", ...]
Falcon
eh.init_falcon(app)
FastAPI / Starlette
eh.init_starlette(app)
Raw WSGI
app = eh.wsgi_middleware(app)
Raw ASGI
app = eh.asgi_middleware(app)
Standalone Rate Limiter
RateLimiter in tollbooth.extras.rate_limiter provides per-IP rate limiting with human-readable limit strings and per-route decorators. It works independently of tollbooth's challenge engine and supports all the same frameworks.
Backend: in-memory LRU (default, evicts oldest entries under pressure) or Redis.
from tollbooth.extras import RateLimiter
rl = RateLimiter(default="100/minute") # in-memory
rl = RateLimiter(default="100/minute", redis_client=r, prefix="myapp") # Redis
Rate strings accept: "10/second", "100/minute", "500/hour", "1000/day" (and plural/abbreviated forms like "10 per min").
Decorator
Auto-detects the framework from the function arguments — works with Django, Falcon, Flask, FastAPI, and Starlette:
@rl.limit("10/minute")
def my_view(request): ... # Django / Flask
@rl.limit("5/second")
def on_get(self, req, resp): ... # Falcon resource method
@rl.limit("20/minute")
async def my_endpoint(request): ... # FastAPI / Starlette
@rl.exempt
def health(request): ... # never rate-limited
Flask
rl.init_flask(app, rate="200/minute") # global, respects @rl.exempt
Django
# settings.py — add the returned class to MIDDLEWARE
RateLimit = rl.as_django_middleware(rate="200/minute")
MIDDLEWARE = ["myapp.middleware.RateLimit", ...]
Falcon / raw WSGI
app = rl.wsgi_middleware(app, rate="500/hour")
FastAPI / Starlette / raw ASGI
# ASGI middleware (global)
app = rl.asgi_middleware(app, rate="500/hour")
# FastAPI per-route dependency
from fastapi import Depends
dep = rl.fastapi_dependency("10/minute")
@app.get("/sensitive")
async def sensitive(_=Depends(dep)):
...
Tests
pip install tollbooth[test]
pytest tests/
Framework and Redis tests skip automatically if packages/services are unavailable.
pip install tollbooth[test,flask,django,fastapi,falcon,starlette,redis]
pytest tests/ -v
Redis tests require a server at 127.0.0.1:6379.
Formatting
pip install black isort
isort . && black .
npx prtfm
Security analysis
snyk code test --include-ignores
Findings in .snyk are permanent ignores for false positives and intentional
patterns (test fixtures, example placeholder keys, demo server bindings).
Contributing
We welcome contributions of all kinds — bug reports, feature requests, docs improvements, and code changes. See CONTRIBUTING.md for guidelines.
Quick links:
- Open an issue — bug reports, feature ideas, questions
- Browse open issues — find something to work on
- Good first issues — great starting points for new contributors
git clone https://github.com/libcaptcha/tollbooth.git
cd tollbooth
pip install -e ".[test,flask,django,fastapi,falcon,starlette,redis,image]"
pytest tests/ -v
Please read our Code of Conduct before participating.
Security
To report a vulnerability, do not open a public issue. See SECURITY.md for responsible disclosure instructions.
Managing screenshots
Screenshots live on the orphan screenshots branch to keep binary assets out of the main history.
Add or update a screenshot:
git checkout screenshots
# add/replace image files
cp ~/new-screenshot.webp .
git add new-screenshot.webp
git commit -m "add new-screenshot.webp"
git push origin screenshots
git checkout main
Reference in README:
<img src="https://raw.githubusercontent.com/libcaptcha/tollbooth/screenshots/filename.webp" alt="description" width="400">
License
Project details
Release history Release notifications | RSS feed
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 tollbooth-0.3.7.tar.gz.
File metadata
- Download URL: tollbooth-0.3.7.tar.gz
- Upload date:
- Size: 131.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3a67edf981ffd6a1b8ef06c53f217458e56ca3b747151ac07b22f2d7c52f6538
|
|
| MD5 |
93b78758b7327026d0603f579056862f
|
|
| BLAKE2b-256 |
1575f6c85e1549cb546555878e1813c6320b5a50f9aa5cd0d8d9a03ca243b273
|
Provenance
The following attestation bundles were made for tollbooth-0.3.7.tar.gz:
Publisher:
publish.yml on libcaptcha/tollbooth
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tollbooth-0.3.7.tar.gz -
Subject digest:
3a67edf981ffd6a1b8ef06c53f217458e56ca3b747151ac07b22f2d7c52f6538 - Sigstore transparency entry: 1235068753
- Sigstore integration time:
-
Permalink:
libcaptcha/tollbooth@9272ccca9933e45a1f35085e581f8453e985d989 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/libcaptcha
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@9272ccca9933e45a1f35085e581f8453e985d989 -
Trigger Event:
push
-
Statement type:
File details
Details for the file tollbooth-0.3.7-py3-none-any.whl.
File metadata
- Download URL: tollbooth-0.3.7-py3-none-any.whl
- Upload date:
- Size: 114.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a576518eb299464715fd491d2bb7268c6ec44ab804cbcdd86d9d4fa2a420c735
|
|
| MD5 |
7318aeb7dc87fbd94841db71a93ad9b3
|
|
| BLAKE2b-256 |
53c08842950a17bbe030d7f11ffdefd50d503c55213088f460f49fb968545472
|
Provenance
The following attestation bundles were made for tollbooth-0.3.7-py3-none-any.whl:
Publisher:
publish.yml on libcaptcha/tollbooth
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tollbooth-0.3.7-py3-none-any.whl -
Subject digest:
a576518eb299464715fd491d2bb7268c6ec44ab804cbcdd86d9d4fa2a420c735 - Sigstore transparency entry: 1235068757
- Sigstore integration time:
-
Permalink:
libcaptcha/tollbooth@9272ccca9933e45a1f35085e581f8453e985d989 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/libcaptcha
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@9272ccca9933e45a1f35085e581f8453e985d989 -
Trigger Event:
push
-
Statement type: