Proof-of-work bot challenge middleware for Python. Zero dependencies.
Project description
tollbooth
Proof-of-work bot challenge middleware for Python. Zero dependencies.
from fastapi import FastAPI
from tollbooth.integrations.fastapi import TollboothMiddleware
app = FastAPI()
app.add_middleware(TollboothMiddleware, secret="your-secret-key")
Bots get a browser challenge page. Humans solve it once, get a cookie, browse freely.
Screenshots
ImageCaptcha() Solution: NWGT5V
SHA256Balloon() Default
Contents
- Screenshots
- Install
- How it works
- Usage
- Challenge types
- Configuration
- Rules
- Integrations
- IP Blocklist
- Redis
- Tests
- Managing screenshots
- License
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] # Image CAPTCHA (Pillow)
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
from flask import Flask
from tollbooth.integrations.flask import Tollbooth
app = Flask(__name__)
tb = Tollbooth(app, secret="your-secret-key")
@app.route("/")
def index():
return "Hello!"
@tb.exempt
@app.route("/health")
def health():
return "ok"
@tb.protect(difficulty=14)
@app.route("/api/data")
def data():
return {"rows": [...]}
Django
# settings.py
TOLLBOOTH = {"secret": "your-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)
└── ImageCaptcha offset -4 → effective 6 6-character solution
| Type | Class | Offset | Solved by | GPU-resistant |
|---|---|---|---|---|
sha256-balloon |
SHA256Balloon |
+0 | browser JS | ✓ |
sha256 |
SHA256 |
+6 | browser JS | ✗ |
image-captcha |
ImageCaptcha |
-4 | human | ✓ |
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)
Image 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. Requires Pillow:
pip install tollbooth[image]
Setup
from tollbooth import ImageCaptcha, TollboothWSGI
app = TollboothWSGI(
app,
secret="your-secret-key", # also used to sign CAPTCHA solution tokens
challenge_handler=ImageCaptcha(
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.
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
from tollbooth import SHA256Balloon, TollboothWSGI
TollboothWSGI(
app,
secret="your-secret-key", # required — signs cookies + HMAC
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)
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
}
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.
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) |
| Weigh | curl/wget (+3), missing Accept (+3), missing Accept-Language (+2), Connection:close (+2) |
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.
| Integration | Import | Middleware class | Per-route | Exempt |
|---|---|---|---|---|
| Flask | tollbooth.integrations.flask |
Tollbooth(app) |
@tb.protect |
@tb.exempt |
| Django | tollbooth.integrations.django |
TollboothMiddleware |
@tollbooth_protect |
@tollbooth_exempt |
| 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>
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
blocklist = IPBlocklist()
blocklist.load() # downloads ~23 MB from GitHub
blocklist.load("/path/to/list.txt") # or load from file
engine = Engine("your-secret-key", blocklist=blocklist)
blocklist.start_updates(interval=86400) # optional daily refresh
Parsed into compact integer ranges with O(log n) binary search. No dependencies.
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, and rules across workers via Redis (or Dragonfly, KeyDB, Valkey).
worker 1 worker 2 worker 3
│ │ │
└───────────────┴───────────────┘
│
┌─────▼──────┐
│ Redis │
│ challenges │
│ secret │
│ config │
└────────────┘
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")
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
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.2.0.tar.gz.
File metadata
- Download URL: tollbooth-0.2.0.tar.gz
- Upload date:
- Size: 52.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b1cb5e29e0ccde46c02ca816c686158207f90224a3cfe2576edf1d3d3e187e79
|
|
| MD5 |
130069e50bf80944812e631e07303a63
|
|
| BLAKE2b-256 |
2e2c177e7834c14b6cf7c286eecbed6d893a5bcb8f5b1a7a6eec179c1422919e
|
Provenance
The following attestation bundles were made for tollbooth-0.2.0.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.2.0.tar.gz -
Subject digest:
b1cb5e29e0ccde46c02ca816c686158207f90224a3cfe2576edf1d3d3e187e79 - Sigstore transparency entry: 1155449499
- Sigstore integration time:
-
Permalink:
libcaptcha/tollbooth@f868138bfbd8d64a0ac43cca40a0d9aec879f802 -
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@f868138bfbd8d64a0ac43cca40a0d9aec879f802 -
Trigger Event:
push
-
Statement type:
File details
Details for the file tollbooth-0.2.0-py3-none-any.whl.
File metadata
- Download URL: tollbooth-0.2.0-py3-none-any.whl
- Upload date:
- Size: 40.7 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 |
7e6ca06f7448f7d66651997d98f0b302dedcadd708af1f0404661d9eac609e2c
|
|
| MD5 |
d4c81229cb2b758f984b4af7e7eea724
|
|
| BLAKE2b-256 |
da2dbba13869c257076ddf8ea7281be651451fd200704f64932698a80c0da518
|
Provenance
The following attestation bundles were made for tollbooth-0.2.0-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.2.0-py3-none-any.whl -
Subject digest:
7e6ca06f7448f7d66651997d98f0b302dedcadd708af1f0404661d9eac609e2c - Sigstore transparency entry: 1155449506
- Sigstore integration time:
-
Permalink:
libcaptcha/tollbooth@f868138bfbd8d64a0ac43cca40a0d9aec879f802 -
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@f868138bfbd8d64a0ac43cca40a0d9aec879f802 -
Trigger Event:
push
-
Statement type: