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

terminal
go get github.com/blockbots-org/libs-blockbots/go
main.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:

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

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

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

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

main.go
blockbots.Options{
    APIKey:   os.Getenv("BLOCKBOTS_API_KEY"),
    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
  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:

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

main.go
blockbots.Options{
    ClientIPHeader: "X-Real-Ip",
}

Or compute it, when only your code knows the shape of the event:

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

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

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

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

protect.go
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
}
ActionMeaningWhat to do
ActionAllowLet it throughCall your handler
ActionBlockA bot failed its checkSend Page — a 403 page
ActionChallengeUnverified visitorSend 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:

protect.go
// 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:

handler.go
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.

OptionDefaultWhat it does
APIKey$BLOCKBOTS_API_KEYRequired. Your API key
APIURL$BLOCKBOTS_API_URL, else productionThe verify-bot endpoint
SDKURLjsDelivr @latestBrowser SDK the verification page imports
Disabledfalsetrue turns protection off, middleware still mounted
DebugfalseSkip the token's IP check — for local development
Retries1Verification attempts in the browser
Delay1sBetween attempts
Timeout1.5sOne cloud call, before the request is let through
CacheTTL1hHow long a crawler verdict is cached
CacheMax10000Verdicts held per instance before the oldest is dropped
KeyCheckTTL1hHow long a confirmed-good API key is trusted
ChallengeStatus200Use 503 to have search engines treat the verification page as a temporary hold
CookieName__bbjwtCookie the browser SDK sets
TrustedProxies$BLOCKBOTS_TRUSTED_PROXIESYour 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_SELFTESTSecret that enables the self-test page
Storein-processShared verdict cache
HTTPClient&http.Client{}Used for the cloud call
Loggerslog.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:

main.go
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