Skip to main content

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, Depends
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.

Why tollbooth over Anubis?

tollbooth Anubis
Language Python (drop-in middleware) Go (standalone reverse proxy)
Dependencies 0 31 direct, ~160 transitive
Code size ~800 lines ~10,000 lines
Integration app.add_middleware(...) Separate process + reverse proxy
PoW algorithm Balloon hashing (memory-hard) Plain SHA-256
Rules format JSON YAML + CEL expressions
Frameworks Flask, Django, FastAPI, Starlette, Falcon None (reverse proxy only)

Security: memory-hard PoW

Anubis uses plain SHA-256 hashing — fast on GPUs and ASICs. An attacker with a GPU farm can solve challenges orders of magnitude faster than a browser.

Tollbooth uses Balloon hashing (Boneh, Corrigan-Gibbs, Schechter 2016) — a memory-hard function that requires spaceCost * 32 bytes per attempt. GPU parallelism is bottlenecked by memory bandwidth, not compute. This makes mass-solving economically impractical.

Integration: middleware vs reverse proxy

Anubis runs as a separate process with a reverse proxy, adding network hops, deployment complexity, and a new failure domain.

Tollbooth is a middleware — it lives in your process, shares your config, and adds zero infrastructure:

# WSGI (Flask, Django)
app = TollboothWSGI(app, secret="key")

# ASGI (FastAPI, Starlette)
app = TollboothASGI(app, secret="key")

Rules: JSON vs YAML+CEL

Anubis requires YAML policies with optional CEL expressions and a complex config struct with GeoIP, ASN, Thoth subscriptions, and 30+ CLI flags.

Tollbooth: one JSON file, four actions, regex matching.

Performance: in-process vs network hop

Anubis proxies every request through a separate Go process — the full request pipeline includes reverse proxy setup, header rewriting, and upstream forwarding.

Tollbooth evaluates rules in-process with zero serialization. Allowed requests add microseconds of overhead. Challenged requests are handled before your app even sees them.

Install

pip install tollbooth

With framework extras:

pip install tollbooth[flask]
pip install tollbooth[django]
pip install tollbooth[fastapi]
pip install tollbooth[starlette]
pip install tollbooth[falcon]

How it works

Browser                             Server
  │                                   │
  │  GET /page                        │
  │──────────────────────────────────►│
  │                                   │  rules evaluate request
  │  429 + challenge page             │  → action: challenge
  │◄──────────────────────────────────│
  │                                   │
  │  Web Workers solve PoW            │
  │  Balloon(random_data + nonce)     │
  │  until ≥ difficulty leading       │
  │  zero bits in hash                │
  │                                   │
  │  POST /.tollbooth/verify          │
  │  { id, nonce, redirect }          │
  │──────────────────────────────────►│
  │                                   │  server verifies PoW
  │  302 + Set-Cookie (JWT)           │  → issues signed cookie
  │◄──────────────────────────────────│
  │                                   │
  │  GET /page (with cookie)          │
  │──────────────────────────────────►│
  │  200 OK                           │  cookie valid → pass through
  │◄──────────────────────────────────│

The challenge page uses navigator.hardwareConcurrency Web Workers to mine in parallel. The JWT cookie is HMAC-SHA256 signed, bound to the client's IP hash, and valid for 7 days.

Usage

Raw WSGI / ASGI

from tollbooth import TollboothWSGI, TollboothASGI

# WSGI
app = TollboothWSGI(your_app, secret="your-secret-key")

# ASGI
app = TollboothASGI(your_app, secret="your-secret-key")

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"

Django

# settings.py
TOLLBOOTH = {"secret": "your-secret-key"}
MIDDLEWARE = [
    "tollbooth.integrations.django.TollboothMiddleware",
    # ...
]

Per-view exemption:

from tollbooth.integrations.django import tollbooth_exempt

@tollbooth_exempt
def health(request):
    return HttpResponse("ok")

FastAPI

from fastapi import FastAPI
from tollbooth.integrations.fastapi import TollboothMiddleware

app = FastAPI()
app.add_middleware(TollboothMiddleware, secret="your-secret-key")

Or as a dependency for specific routes:

from tollbooth.integrations.fastapi import TollboothDep

protect = TollboothDep("your-secret-key")

@app.get("/protected", dependencies=[Depends(protect)])
def protected():
    return {"ok": True}

Starlette

from starlette.applications import Starlette
from tollbooth.integrations.starlette import TollboothMiddleware

app = Starlette()
app.add_middleware(TollboothMiddleware, secret="your-secret-key")

Falcon

import falcon
from tollbooth.integrations.falcon import TollboothMiddleware

app = falcon.App(middleware=[
    TollboothMiddleware(secret="your-secret-key"),
])

Configuration

Pass options as keyword arguments to any integration:

TollboothWSGI(
    app,
    secret="your-secret-key",
    default_difficulty=12,    # leading zero bits (default: 10)
    space_cost=2048,          # balloon memory blocks (default: 1024)
    time_cost=1,              # mixing rounds (default: 1)
    delta=3,                  # random lookups per step (default: 3)
    cookie_ttl=86400,         # cookie lifetime seconds (default: 604800)
    challenge_ttl=1800,       # challenge validity seconds (default: 1800)
    challenge_threshold=5,    # weight sum to trigger challenge (default: 5)
    branding=True,            # show "Protected by tollbooth" (default: True)
)

Each +1 difficulty doubles expected solve time. Higher space_cost increases memory per attempt (space_cost * 32 bytes).

Rules

Rules are evaluated top-to-bottom. First matching terminal action (allow, deny, challenge) wins. weigh rules accumulate weight — if the sum reaches challenge_threshold, a challenge is issued.

Format

[
    {
        "name": "rule-name",
        "action": "allow | deny | challenge | weigh",
        "user_agent": "regex",
        "path": "regex",
        "headers": { "Header-Name": "regex" },
        "remote_addresses": ["192.168.0.0/24"],
        "difficulty": 12,
        "weight": 3
    }
]

All match fields are optional. A rule with no match fields matches everything. All fields use regex except remote_addresses (CIDR notation).

Actions

Action Behavior
allow Pass through immediately
deny Return 403
challenge Serve PoW challenge page
weigh Add weight to score, continue evaluating

Default rules

Tollbooth ships with rules.json covering:

Deny — Cloudflare Workers abuse, known bad bots, vulnerability scanners, WordPress probes, dotfile probes, shell probes, path traversal attempts

Allow.well-known/, favicon.ico, robots.txt, health checks, search engines, feed readers, monitoring services, link previews, archive.org

Challenge — AI bots (difficulty 10), headless browsers (6), aggressive scrapers (8), empty user agents (6), generic browsers

Weigh — curl/wget (+3), missing Accept header (+3), missing Accept-Language (+2), Connection: close (+2)

Custom rules

Override by passing a rules_file path or constructing a Policy directly:

from tollbooth import Policy, Rule, TollboothWSGI

policy = Policy(rules=[
    Rule(name="internal", action="allow",
         remote_addresses=["10.0.0.0/8"]),
    Rule(name="api-bots", action="challenge",
         path="^/api/", difficulty=14),
    Rule(name="default", action="challenge"),
])

app = TollboothWSGI(your_app, secret="key", policy=policy)

Rule templates

Block AI scrapers:

{
    "name": "ai-bots",
    "action": "deny",
    "user_agent": "(?i:GPTBot|ChatGPT|Claude-Web|CCBot|Bytespider)"
}

Protect API endpoints:

{ "name": "api-protect", "action": "challenge", "path": "^/api/", "difficulty": 14 }

Allowlist internal IPs:

{ "name": "internal", "action": "allow", "remote_addresses": ["10.0.0.0/8", "172.16.0.0/12"] }

Weight scoring for suspicious signals:

[
    { "name": "no-accept", "action": "weigh", "weight": 3, "headers": { "Accept": "^$" } },
    { "name": "no-lang", "action": "weigh", "weight": 2, "headers": { "Accept-Language": "^$" } },
    { "name": "curl", "action": "weigh", "weight": 3, "user_agent": "(?i:^curl/|^Wget/)" }
]

With challenge_threshold=5, curl (weight 3) + missing Accept-Language (weight 2) = 5, triggers a challenge.

Integrations

All integrations share the same options via TollboothBase:

from tollbooth.integrations.base import TollboothBase

tb = TollboothBase(
    secret="key",
    exclude=[r"^/static/", r"^/health$"],  # regex skip list
    json_mode=True,  # return JSON instead of HTML challenges
)
Integration Middleware class Per-route Exempt decorator
Flask Tollbooth(app) @tb.protect @tb.exempt
Django TollboothMiddleware @tollbooth_protect @tollbooth_exempt
FastAPI TollboothMiddleware TollboothDep exclude=[...]
Starlette TollboothMiddleware exclude=[...]
Falcon TollboothMiddleware tollbooth_hook exclude=[...]
WSGI TollboothWSGI
ASGI TollboothASGI

JSON mode

For API/SPA backends, enable json_mode=True. Challenges return JSON instead of HTML:

{
    "challenge": {
        "id": "abc123",
        "data": "random_hex",
        "difficulty": 10,
        "space_cost": 1024,
        "time_cost": 1,
        "delta": 3,
        "verify_path": "/.tollbooth/verify",
        "redirect": "/api/data"
    }
}

Solve with the sha256-balloon client library and POST the nonce to the verify endpoint.

License

MIT

Project details


Download files

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

Source Distribution

tollbooth-0.1.1.tar.gz (36.0 kB view details)

Uploaded Source

Built Distribution

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

tollbooth-0.1.1-py3-none-any.whl (24.5 kB view details)

Uploaded Python 3

File details

Details for the file tollbooth-0.1.1.tar.gz.

File metadata

  • Download URL: tollbooth-0.1.1.tar.gz
  • Upload date:
  • Size: 36.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for tollbooth-0.1.1.tar.gz
Algorithm Hash digest
SHA256 ee16c8dd24385e93ebb8d3f76df9e446dd91d2d78f20724c0e4f56aadac598f2
MD5 c59c4e99f4a6703c96a44f05b50f69db
BLAKE2b-256 129919ae445012660c937f5f9eb533ccace822c7301c69f0a86c42af69161254

See more details on using hashes here.

Provenance

The following attestation bundles were made for tollbooth-0.1.1.tar.gz:

Publisher: publish.yml on libcaptcha/tollbooth

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

File details

Details for the file tollbooth-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: tollbooth-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 24.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for tollbooth-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 d5fc4727a24dbf199cb9aef547414b201f19921c1deaa4f431b454b08db7f26f
MD5 45aa07eb46b5f9984ad92271e713d88c
BLAKE2b-256 5253102872d4cc60786182d67d1a3c810d98d41390e3b9494ac2744a9d85ae48

See more details on using hashes here.

Provenance

The following attestation bundles were made for tollbooth-0.1.1-py3-none-any.whl:

Publisher: publish.yml on libcaptcha/tollbooth

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

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page