Server
Go
Protect a Go site from bots with one net/http middleware — standard library only, no dependencies.
Overview
This guide adds BlockBots to a Go web server. You will get one module and wrap your mux — detection then runs on every request before your handlers do.
It takes about five minutes. Standard library only, 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
- Go 1.21 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
go get github.com/blockbots-org/libs-blockbots/go
import blockbots "github.com/blockbots-org/libs-blockbots/go"
The import path ends in /go but the package is named blockbots, so the alias keeps it readable.
Step 2 — Wrap your mux
Build the client once at startup and wrap the mux before it serves anything:
package main
import (
"log"
"net/http"
"os"
blockbots "github.com/blockbots-org/libs-blockbots/go"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Welcome"))
})
bb, err := blockbots.New(blockbots.Options{
APIKey: os.Getenv("BLOCKBOTS_API_KEY"),
})
if err != nil {
log.Fatal(err)
}
log.Fatal(http.ListenAndServe(":8080", bb.Handler(mux)))
}
There is nothing to call. The middleware decides on its own and answers the requests it stops.
New returns an error for exactly one reason — no API key, from the option or from the environment. BLOCKBOTS_API_KEY is picked up automatically, so blockbots.New(blockbots.Options{}) also works when that variable is set.
Order matters. A wrapper applied to one route never sees a request another route has already answered, so nothing is protected. Wrap the mux, not a handler inside it — this is the Go equivalent of including the PHP file after the first
echo.
A Client is safe for any number of goroutines and is meant to be built once, at startup, not per request. It holds the verdict cache and a pooled HTTP client; building one per request throws both away.
chi, gorilla/mux, alice
Once bound, bb.Handler is a plain func(http.Handler) http.Handler, so it drops straight into anything that speaks that shape:
r := chi.NewRouter()
r.Use(bb.Handler)
For Gin, Echo and Fiber — whose handler signatures are their own — see Making the decision yourself.
Step 3 — Exclude what must not be checked
Health checks, webhooks, your admin area and your own API routes should go straight through:
bb, err := blockbots.New(blockbots.Options{
APIKey: os.Getenv("BLOCKBOTS_API_KEY"),
Exclude: []string{"/healthz", "/webhooks", "/api", "/admin"},
})
A prefix matches that path and everything under it — /api covers /api and /api/users, but not /api-docs. For what a prefix cannot express, use ExcludeFunc:
blockbots.Options{
Exclude: []string{"/webhooks/stripe"},
ExcludeFunc: func(r *http.Request) bool {
return r.Header.Get("X-Internal-Token") == internalToken
},
}
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:
blockbots.Options{
APIKey: os.Getenv("BLOCKBOTS_API_KEY"),
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
go go1.22.3
platform generic (has a socket)
ed25519 yes
library active yes
disabled option false
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
trusted proxy ranges 0 configured
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. The secret is compared in constant time. Leave Selftest unset and the page does not exist.
ip came from is the line that explains a verification loop.
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:
blockbots.Options{
APIKey: os.Getenv("BLOCKBOTS_API_KEY"),
TrustedProxies: "203.0.113.0/24,198.51.100.7",
}
Use the address ranges your CDN connects from — your provider publishes them. Addresses and CIDR blocks both work, comma separated, and BLOCKBOTS_TRUSTED_PROXIES is read if the option is empty. X-Forwarded-For is parsed 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.
Step 6 — Serverless
net/http always fills RemoteAddr, so this is a narrower problem in Go than elsewhere — it only arises in a shim that builds an *http.Request from an event.
Vercel, Netlify and Deno Deploy are detected from their own environment variables and need nothing.
AWS Lambda behind API Gateway and Google Cloud Run are deliberately not detected, unlike in the Python and Node clients' serverless paths. Both append to X-Forwarded-For rather than overwriting it, and which end of that chain is spoofable is a judgement call this library will not make for you. Name the source yourself:
blockbots.Options{
ClientIPHeader: "X-Real-Ip",
}
Or compute it, when only your code knows the shape of the event:
blockbots.Options{
ClientIP: func(r *http.Request) string {
return r.Header.Get("X-Forwarded-For") // your own parsing
},
}
Why it is not guessed: with no verifiable peer 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 token's IP binding at once.
Without an address, protection switches itself off for those requests and says so in your log — deliberately, because a token with no address to bind to would send every visitor back to the verification page forever. The reason appears as ip_unresolved.
Step 7 — Running more than one instance
Nothing to do, but worth knowing: the default verdict cache is an in-process map, and each instance has its own. A crawler verdict is learned once per instance rather than once per site, which costs a few extra cloud calls and nothing else.
To share them, pass a Store. It is two methods, so a Redis adapter is short:
type redisStore struct{ rdb *redis.Client }
func (s redisStore) Get(ctx context.Context, key string) (string, bool, error) {
v, err := s.rdb.Get(ctx, key).Result()
if err == redis.Nil {
return "", false, nil // a miss is not an error
}
return v, err == nil, err
}
func (s redisStore) Set(ctx context.Context, key, value string, ttl time.Duration) error {
return s.rdb.Set(ctx, key, value, ttl).Err()
}
bb, err := blockbots.New(blockbots.Options{
APIKey: os.Getenv("BLOCKBOTS_API_KEY"),
Store: redisStore{rdb: rdb},
})
A store that returns an error is logged and treated as a miss, so a Redis outage costs cloud calls, not requests. Keys are shared with the PHP, Node and Python 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. Check uses the request's own context, so a cancelled request abandons the cloud call with it. 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 slog.Default() — nothing is printed and nothing panics. Hand over your own logger to route it:
blockbots.Options{Logger: slog.New(handler)}
Making the decision yourself
For anything Handler does not cover — or when you would rather log than block — Check reads a request and returns a verdict without touching the response:
d := bb.Check(r)
// blockbots.Decision{
// Action: blockbots.ActionChallenge,
// Allow: false,
// Reason: "unverified",
// IP: "203.0.113.9",
// UA: "Mozilla/5.0 …",
// Page: *blockbots.Page,
// }
if !d.Allow {
d.Page.WriteTo(w, r)
return
}
Action | Meaning | What to do |
|---|---|---|
ActionAllow | Let it through | Call your handler |
ActionBlock | A bot failed its check | Send Page — a 403 page |
ActionChallenge | Unverified visitor | Send Page — the verification page, which reloads once passed |
Page is {Status int, Header http.Header, Body string}, so a framework that writes the response itself can do that instead of calling WriteTo. It is nil whenever Allow is true.
Reason is for your logs: token_valid, verified_bot, cached_allowed, cached_disallowed, not_a_navigation, excluded, unverified, ip_unresolved, unauthorized, service_disabled, disabled, api_* when the cloud could not be reached, or the cloud's own reason for blocking such as fake_googlebot.
Remember the self-test, which Handler would otherwise have served for you:
// Gin
func Protect(bb *blockbots.Client) gin.HandlerFunc {
return func(c *gin.Context) {
if bb.IsSelftestRequest(c.Request) {
bb.Selftest(c.Request).WriteTo(c.Writer, c.Request)
c.Abort()
return
}
if d := bb.Check(c.Request); !d.Allow {
d.Page.WriteTo(c.Writer, c.Request)
c.Abort()
return
}
c.Next()
}
}
Handler leaves the decision in the request context, so a handler behind it can read what happened without doing the work again:
if d, ok := blockbots.DecisionFrom(r); ok {
log.Print(d.Reason, d.IP)
}
Configuration
Every field's zero value is a working default, so only APIKey is required — and even that may come from the environment.
| Option | Default | What it does |
|---|---|---|
APIKey | $BLOCKBOTS_API_KEY | Required. Your API key |
APIURL | $BLOCKBOTS_API_URL, else production | The verify-bot endpoint |
SDKURL | jsDelivr @latest | Browser SDK the verification page imports |
Disabled | false | true turns protection off, middleware still mounted |
Debug | false | Skip the token's IP check — for local development |
Retries | 1 | Verification attempts in the browser |
Delay | 1s | Between attempts |
Timeout | 1.5s | One cloud call, before the request is let through |
CacheTTL | 1h | How long a crawler verdict is cached |
CacheMax | 10000 | Verdicts held per instance before the oldest is dropped |
KeyCheckTTL | 1h | How long a confirmed-good API key is trusted |
ChallengeStatus | 200 | Use 503 to have search engines treat the verification page as a temporary hold |
CookieName | __bbjwt | Cookie the browser SDK sets |
TrustedProxies | $BLOCKBOTS_TRUSTED_PROXIES | Your CDN or load balancer addresses and CIDRs, comma separated |
ClientIPHeader | — | Header to read the client address from |
ClientIP | — | Function computing it outright |
Exclude | — | Path prefixes left unchecked |
ExcludeFunc | — | Your own rule for what to leave unchecked |
Selftest | $BLOCKBOTS_SELFTEST | Secret that enables the self-test page |
Store | in-process | Shared verdict cache |
HTTPClient | &http.Client{} | Used for the cloud call |
Logger | slog.Default() | Receives the conditions that make an install look protected while it is not |
Unlike the PHP and Node clients, there is no degraded crypto mode to guard: crypto/ed25519 is pure Go and present in every build. The library still verifies a signature of its own at startup, so the fail-closed rule stays expressed in code — if that ever fails, protection disables itself rather than accepting tokens it has not checked.
For local development the cleanest switch is to turn it off:
blockbots.Options{
APIKey: key,
Disabled: os.Getenv("ENV") != "production",
}
Your API key is tied to its registered domain, so the cloud rejects requests from localhost anyway. Debug: true relaxes the token'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
- Python — the same protection for Django or FastAPI
- PHP — the same protection for a PHP site