Server
Python
Protect a Django, Flask or FastAPI site from bots with one middleware — one dependency.
Overview
This guide adds BlockBots to a Python web app. You will install one package and add one middleware — detection then runs on every request before your views do.
It takes about five minutes. One dependency, no database, no build step.
This guide covers server-side protection. If your pages are rendered entirely in the browser, see the JavaScript guide. The two work together.
Prerequisites
- Python 3.9 or higher
- An API key — create one at panel.blockbots.org under Settings → API Keys
- The domain registered on that key, exactly as visitors reach it
Step 1 — Install
pip install blockbots
Step 2 — Add the middleware
Pick the one that matches your framework. In all three cases it must run before your own code: a check that runs after your view has already answered protects nothing.
Django
MIDDLEWARE = [
"blockbots.django_middleware.BlockbotsMiddleware", # first
"django.middleware.security.SecurityMiddleware",
# ... the rest of yours
]
BLOCKBOTS = {
"api_key": "YOUR_API_KEY",
"exclude": ["/admin"],
}
Put it first in the list. Django runs middleware top-down on the way in, so anything above it answers before the check does.
Flask, Bottle, Pyramid — any WSGI app
from flask import Flask
from blockbots.wsgi import BlockbotsMiddleware
app = Flask(__name__)
app.wsgi_app = BlockbotsMiddleware(app.wsgi_app, api_key="YOUR_API_KEY")
@app.get("/")
def index():
return "Welcome"
FastAPI, Starlette, Litestar — any ASGI app
from fastapi import FastAPI
from blockbots.asgi import BlockbotsMiddleware
app = FastAPI()
app.add_middleware(BlockbotsMiddleware, api_key="YOUR_API_KEY")
@app.get("/")
def index():
return "Welcome"
The verify-bot call is blocking, so the ASGI middleware runs it in a worker thread — your event loop is never held for the length of a network round trip.
There is nothing to call. The middleware decides on its own and answers the requests it stops.
Read the key from the environment rather than hard-coding it — BLOCKBOTS_API_KEY is picked up automatically, so you can leave the argument out entirely:
app.wsgi_app = BlockbotsMiddleware(app.wsgi_app)
Step 3 — Exclude what must not be checked
Health checks, webhooks, your admin area and your own API routes should go straight through:
BlockbotsMiddleware(
app.wsgi_app,
exclude=["/healthz", "/webhooks", "/api", "/admin"],
)
A string matches that path and everything under it — /api covers /api/users, but not /api-docs. Compiled regexes and callables work too:
import re
exclude = [
"/webhooks", # path and everything under it
re.compile(r"^/internal/"), # regular expression
lambda environ: environ.get("HTTP_X_INTERNAL") == "1", # your own rule
]
On Django, add
"/admin"unless you want staff hitting the verification page.
Step 4 — Check it works
Open your site in a private window. You should see a verification screen for a second, then your own page.
To confirm what is going on under the hood, switch on the self-test:
BlockbotsMiddleware(app.wsgi_app, selftest="pick-a-long-secret")
Then request any page with that secret:
curl "https://your-site.com/?bb-selftest=pick-a-long-secret"
A healthy install answers like this:
Blockbots self-test
environment
python 3.12.3
platform generic (has a socket)
cryptography yes
library active yes
enabled option true
service state ok
this request
socket address 203.0.113.9
trusted proxy NO
resolved client ip 203.0.113.9
ip came from socket
looks like a browser yes
navigation yes
host your-site.com
path excluded NO
token
__bbjwt cookie absent - visitor has not been verified yet
cache
store memory
entries 14
ttl 3600s
api
url https://api.blockbots.org/v1/verify-bot
round trip 86 ms
reachable yes
answered not_bot
Nothing secret is printed — not your API key, not the visitor's token. Remove the selftest option when you are done; without it the page does not exist.
Step 5 — If you use a CDN or reverse proxy
Cloudflare is recognised automatically, and so are nginx, Docker and a load balancer on your own network. Any other CDN needs one line, otherwise every visitor arrives as the same address and nobody can pass verification:
BlockbotsMiddleware(app.wsgi_app, trusted_proxies="203.0.113.0/24,198.51.100.7")
Use the address ranges your CDN connects from — your provider publishes them. X-Forwarded-For is read right to left, skipping your own hops.
In the self-test, trusted proxy should read yes and resolved client ip should be the visitor's real address, not your CDN's.
Django's
SECURE_PROXY_SSL_HEADERandUSE_X_FORWARDED_HOSTare Django's own settings and do not affect BlockBots. Usetrusted_proxies.
Step 6 — Serverless
Vercel, Netlify and Deno Deploy are recognised from their own environment and need nothing.
AWS Lambda and Google Cloud Run usually work too, because Mangum, Zappa and the Serverless framework all fill in the client address from the event. If the self-test shows ip came from unresolved, name the header your platform sets:
BlockbotsMiddleware(app.wsgi_app, client_ip="x-real-ip")
Why it is not guessed: with no connection to check a header against, trusting one would let any visitor choose their own address with a single curl -H flag, which defeats the verdict cache and the cookie's IP binding at once.
Without an address, protection switches itself off for those requests and says so in your log — deliberately, because a cookie with no address to bind to would send every visitor back to the verification page forever.
Step 7 — Behind gunicorn or uvicorn workers
Nothing to do, but worth knowing: the verdict cache lives in the process, and each worker has its own. A crawler verdict is learned once per worker rather than once per site, which costs a few extra cloud calls and nothing else.
To share them, hand over a store. Any object with two methods works:
import redis
from blockbots import Blockbots
client = redis.Redis(decode_responses=True)
class RedisStore:
def get(self, key):
return client.get(key)
def set(self, key, value, ttl):
client.set(key, value, ex=ttl)
bb = Blockbots(api_key=..., store=RedisStore())
app.wsgi_app = BlockbotsMiddleware(app.wsgi_app, client=bb)
decode_responses=True matters — the store deals in strings. A store that raises is logged and treated as a miss, so a Redis outage costs cloud calls, not requests. Keys are shared with the PHP and Node clients, so one Redis can serve a mixed estate.
How it works
- A visitor with a valid cookie passes through instantly — no network call.
- Anything that does not look like a browser is checked against the cloud. Crawlers you allow, such as Googlebot, get your content; impostors using their name get
403. - Everyone else sees the verification page, gets a signed cookie, and the page reloads.
Verdicts are cached, so a crawler costs one cloud call per hour rather than one per page. fetch and XHR requests are never interrupted, and POST bodies are never swallowed. If the cloud is unreachable, the middleware steps aside and your site keeps serving.
A dead or expired key never reaches your visitors. Before the verification page is served for the first time, BlockBots asks whether the cloud is serving your key at all. A rejection switches protection off for five minutes rather than handing out a page whose script would fail the same way and never reload — so a lapsed subscription leaves your site unprotected, not broken. The answer is cached, so this costs one request an hour, not one per visitor, and renewing a subscription takes effect on its own with no restart.
Everything goes to the blockbots logger — nothing is printed and nothing is raised:
import logging
logging.getLogger("blockbots").setLevel(logging.INFO)
Making the decision yourself
For anything the three adapters do not cover — or when you would rather log than block — check() reads a request and returns a verdict without touching the response:
from blockbots import Blockbots
from blockbots.wsgi import request_from_environ
bb = Blockbots(api_key=...)
decision = bb.check(request_from_environ(environ))
# Decision(action='challenge', allow=False, reason='unverified',
# ip='203.0.113.9', user_agent='…', response=Response(...))
if not decision.allow:
return send(decision.response.status,
decision.response.headers,
decision.response.body)
action | Meaning | What to do |
|---|---|---|
allow | Let it through | Call your view |
block | A bot failed its check | Send response — a 403 page |
challenge | Unverified visitor | Send response — the verification page, which reloads once passed |
reason is for your logs: token_valid, verified_bot, cached_allowed, not_a_navigation, excluded, unverified, api_request_failed, ip_unresolved, unauthorized, disabled, or the cloud's own reason for blocking such as fake_googlebot.
Build the request from whatever you have:
from blockbots.wsgi import request_from_environ # a WSGI environ
from blockbots.asgi import request_from_scope # an ASGI scope
from blockbots.django_middleware import request_from_django # a Django HttpRequest
from blockbots import build_request_from_url # a URL plus headers
Each adapter also leaves the decision where a view can read it without redoing the work: environ["blockbots.decision"], scope["blockbots.decision"], request.blockbots.
Configuration
Everything except the key is optional.
| Option | Default | What it does |
|---|---|---|
api_key | $BLOCKBOTS_API_KEY | Required. Your API key |
trusted_proxies | — | Your CDN or load balancer addresses, comma separated or a list |
client_ip | — | Header name or callable, for hosts with no connection to read |
exclude | () | Paths left unchecked: string prefix, compiled regex, or callable |
selftest | — | Secret that enables the self-test page |
enabled | True | False turns protection off entirely |
retries | 1 | Verification attempts in the browser |
delay | 1000 | Milliseconds between attempts |
timeout | 1.5 | Seconds to wait for the cloud before letting the request through |
cache_ttl | 3600 | How long a crawler verdict is cached, in seconds |
cache_max | 10000 | Verdicts held per worker before the oldest is dropped |
key_check_ttl | 3600 | How long a confirmed-good API key is trusted, in seconds |
store | in-process | Shared cache, for several workers or hosts |
challenge_status | 200 | Use 503 to have search engines treat the verification page as a temporary hold |
cookie_name | __bbjwt | Cookie the browser SDK sets |
debug | False | Skip the IP check on the cookie — for local development |
cryptography is the only dependency, and it is not optional: every decision is anchored in the Ed25519 signature of the session cookie, and the standard library cannot verify one. Without it the library disables itself rather than skipping verification.
For local development the cleanest switch is to turn it off:
BLOCKBOTS = {"api_key": ..., "enabled": os.environ.get("ENV") == "production"}
Your API key is tied to its registered domain, so the cloud rejects requests from localhost anyway. debug=True relaxes the cookie's IP check if you do want to test the real flow; never use it in production.
Next steps
- JavaScript — add the browser SDK alongside this
- Node.js — the same protection for an Express app
- Go — the same protection for a net/http server
- PHP — the same protection for a PHP site