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

terminal
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

settings.py
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

app.py
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

main.py
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.py
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:

app.py
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:

app.py
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:

app.py
BlockbotsMiddleware(app.wsgi_app, selftest="pick-a-long-secret")

Then request any page with that secret:

terminal
curl "https://your-site.com/?bb-selftest=pick-a-long-secret"

A healthy install answers like this:

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

app.py
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_HEADER and USE_X_FORWARDED_HOST are Django's own settings and do not affect BlockBots. Use trusted_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:

app.py
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:

store.py
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

  1. A visitor with a valid cookie passes through instantly — no network call.
  2. 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.
  3. 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:

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

middleware.py
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)
actionMeaningWhat to do
allowLet it throughCall your view
blockA bot failed its checkSend response — a 403 page
challengeUnverified visitorSend 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:

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

OptionDefaultWhat it does
api_key$BLOCKBOTS_API_KEYRequired. 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
enabledTrueFalse turns protection off entirely
retries1Verification attempts in the browser
delay1000Milliseconds between attempts
timeout1.5Seconds to wait for the cloud before letting the request through
cache_ttl3600How long a crawler verdict is cached, in seconds
cache_max10000Verdicts held per worker before the oldest is dropped
key_check_ttl3600How long a confirmed-good API key is trusted, in seconds
storein-processShared cache, for several workers or hosts
challenge_status200Use 503 to have search engines treat the verification page as a temporary hold
cookie_name__bbjwtCookie the browser SDK sets
debugFalseSkip 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:

settings.py
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