# How to detect VPN users in Go: net/http, chi and echo

Add VPN detection to a Go server with the net/http middleware: read X-Forwarded-For from the right, then decide signup and checkout in chi or echo.

This tutorial adds VPN detection to a Go server with [`sdk-go-nethttp`](https://docs.vpndetection.io/integrations/net-http), our official middleware for `net/http`, which chi and echo take too. By the end, signup asks visitors on a VPN, a residential proxy or Tor to confirm their email, and checkout turns them away. Before either works, the middleware has to see the visitor's address, and `net/http` has no trusted-proxy setting: the code that comes up for this reads `X-Forwarded-For` from the wrong end, where a visitor on a VPN writes any address they like. We ran every snippet against the live API on October 11, 2026, with Go 1.27.2, `sdk-go-nethttp` 2.1.12, chi 5.3.2 and echo 5.4.0, and every output below is from those runs.

## Look up every request

```bash
go get github.com/vpndetection-io/sdk-go-nethttp/v2
```

The middleware needs Go 1.26 or newer. Get an API key in the [console](https://app.vpndetection.io): a free one covers 50,000 lookups a month. Without a key, the API allows 1,000 lookups a day per source address, and once deployed, every lookup comes from your server's address.

```go
// main.go
package main

import (
	"encoding/json"
	"log"
	"net/http"
	"os"

	vpndetectionhttp "github.com/vpndetection-io/sdk-go-nethttp/v2"
	"github.com/vpndetection-io/sdk-go/v5/middleware"
)

func main() {
	guard := vpndetectionhttp.Must(vpndetectionhttp.Options{
		Options: middleware.Options[*http.Request]{
			APIKey: os.Getenv("VPNDETECTION_API_KEY"),
		},
	})

	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		lookup := vpndetectionhttp.FromContext(r.Context())
		answer := map[string]any{"ip": lookup.IP, "result": lookup.Result}
		if lookup.Err != nil {
			answer["error"] = lookup.Err.Error()
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(answer)
	})

	log.Fatal(http.ListenAndServe(":8000", guard(mux)))
}
```

The middleware looks the visitor up before your handler runs and puts the answer on the request context, where `FromContext` reads it. Nothing is blocked. `Must` panics on options it can't use; `New` returns the error instead.

We sent a request to this app from `193.32.248.66`, a Mullvad server in Berlin from [Mullvad's own server list](https://mullvad.net/en/servers). Part of what came back under `result`:

```json
{
  "is_vpn": true,
  "is_hosting": true,
  "is_tor": false,
  "is_resproxy": false,
  "vpn": {
    "confidence": "high",
    "last_seen": "2026-10-10",
    "method": "scan",
    "provider": "mullvad"
  }
}
```

That's a key on the Max plan. The answer also has `is_relay`, `is_cdn`, `is_dcproxy` and `is_mobproxy`, and a detail object under each flag. A home broadband address on a US cable ISP came back with every flag `false`. A private or reserved address never reaches the API: the middleware answers it in the process, with `is_bogon` set.

## Read X-Forwarded-For from the right

The middleware looks up `r.RemoteAddr`, the address that opened the connection, since that's the only one `net/http` knows. Behind nginx or a load balancer, that's the proxy. We put nginx 1.29.1 in front of the app on the same host, with the usual `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for`. A visitor on the Mullvad server sent `X-Forwarded-For: 1.1.1.1`, through nginx and then straight to the app.

Two fixes come up for this. [ProxyTracer's Go guide](https://proxytracer.com/docs/integration/go) takes the first entry in the header:

```go
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
    ip = strings.Split(r.RemoteAddr, ":")[0]
} else {
    ip = strings.Split(ip, ",")[0]
}
```

[`sebest/xff`](https://github.com/sebest/xff), a middleware that rewrites `r.RemoteAddr` from the header, takes its first public address, and its `AllowedSubnets` option names whose header it believes. We ran each with our middleware, ProxyTracer's lines as its `IPSelector` and `xff` wrapped around it. The address it looked up:

| Client address | Through nginx | Straight to the app |
|---|---|---|
| `r.RemoteAddr`, the default | `127.0.0.1` | `193.32.248.66` |
| ProxyTracer's first entry | `1.1.1.1` | `1.1.1.1` |
| `xff.Default()` | `1.1.1.1` | `1.1.1.1` |
| `xff` allowing only `127.0.0.0/8` | `1.1.1.1` | `193.32.248.66` |
| `XFFIPSelector(1)` | `193.32.248.66` | `1.1.1.1` |
| `XFFIPSelector(1)`, listening on `127.0.0.1` | `193.32.248.66` | refused |

With the default, every visitor through nginx is nginx, a private address the middleware answers locally, so nobody is ever flagged. The one sign is a line in the server's log:

```
WARN resolved the client address as 127.0.0.1, which is not a public address. If this application runs behind a proxy or load balancer, configure its trusted-proxy setting or pass an IPSelector that reads your edge's header.
```

nginx appends the address it saw, so the visitor's own entry comes first, and both fixes take it. `1.1.1.1` answers `is_hosting` but not `is_vpn`, so a VPN check waves them through. `AllowedSubnets` ignores the header from a visitor who skips nginx, and through nginx it still reads the left end.

Count from the right instead. `XFFIPSelector(1)` takes the last entry, the address your one proxy saw:

```go
	guard := vpndetectionhttp.Must(vpndetectionhttp.Options{
		Options: middleware.Options[*http.Request]{
			APIKey:     os.Getenv("VPNDETECTION_API_KEY"),
			IPSelector: vpndetectionhttp.XFFIPSelector(1),
		},
	})
	// ...
	log.Fatal(http.ListenAndServe("127.0.0.1:8000", guard(mux)))
```

Straight to the app, the visitor's header has nothing after their own entry, so `XFFIPSelector(1)` read `1.1.1.1` there. Listening on `127.0.0.1`, only nginx on the same host can connect: the visitor who tried was refused. With the proxy on another machine, listen on a private address only it can reach, and set the count to the number of proxies a request passes.

Behind a CDN, `r.RemoteAddr` is the CDN. From a Cloudflare address, the first app looked up that address and found `is_cdn: true`. Read the header the CDN writes instead:

```go
	cloudflare := vpndetectionhttp.HeaderIPSelector("CF-Connecting-IP")
	guard := vpndetectionhttp.Must(vpndetectionhttp.Options{
		Options: middleware.Options[*http.Request]{
			APIKey:     os.Getenv("VPNDETECTION_API_KEY"),
			IPSelector: cloudflare,
		},
	})
```

From Cloudflare's address, `CF-Connecting-IP: 193.32.248.66` came back as the VPN, and with the header missing, the selector fell back to the connecting address. Straight to the app, `CF-Connecting-IP: 1.1.1.1` was looked up as `1.1.1.1`, so let only [Cloudflare's ranges](https://www.cloudflare.com/ips/) reach your origin. Behind Akamai, the header is `True-Client-IP`.

## Decide per route in chi and echo

Most routes don't care who's on a VPN; signup and checkout do. In chi, put the middleware on a group, and only the routes in it cost a lookup:

```go
// main.go
package main

import (
	"encoding/json"
	"log"
	"net/http"
	"os"

	"github.com/go-chi/chi/v5"
	vpndetectionhttp "github.com/vpndetection-io/sdk-go-nethttp/v2"
	vpndetection "github.com/vpndetection-io/sdk-go/v5"
	"github.com/vpndetection-io/sdk-go/v5/middleware"
)

// Checkout refuses a VPN, Tor, or a residential proxy exit seen 5 times or more.
var refuseCheckout = vpndetectionhttp.Conditions{
	{"is_vpn": true},
	{"is_tor": true},
	{"is_resproxy": true, "resproxy": vpndetectionhttp.BlockCondition{
		"hits": vpndetectionhttp.Gte(5),
	}},
}

func main() {
	guard := vpndetectionhttp.Must(vpndetectionhttp.Options{
		Options: middleware.Options[*http.Request]{
			APIKey:     os.Getenv("VPNDETECTION_API_KEY"),
			IPSelector: vpndetectionhttp.XFFIPSelector(1),
		},
	})

	r := chi.NewRouter()
	r.Get("/", home) // no lookup
	r.Group(func(r chi.Router) {
		r.Use(guard)
		r.Post("/signup", signup)
		r.Post("/checkout", checkout)
	})

	log.Fatal(http.ListenAndServe("127.0.0.1:8000", r))
}

func home(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]string{"hello": "world"})
}

// Nobody is refused, but a masked visitor confirms their email first.
func signup(w http.ResponseWriter, r *http.Request) {
	result := vpndetectionhttp.FromContext(r.Context()).Result
	masked := result == nil ||
		result.IsVpn ||
		vpndetection.BoolValue(result.IsResproxy) ||
		vpndetection.BoolValue(result.IsTor)
	writeJSON(w, http.StatusOK, map[string]bool{"confirm_email": masked})
}

func checkout(w http.ResponseWriter, r *http.Request) {
	result := vpndetectionhttp.FromContext(r.Context()).Result
	if result != nil && middleware.Matches(refuseCheckout, result) {
		msg := "Please turn off your VPN or proxy to check out."
		writeJSON(w, http.StatusForbidden, map[string]string{"error": msg})
		return
	}
	writeJSON(w, http.StatusOK, map[string]bool{"ordered": true})
}

func writeJSON(w http.ResponseWriter, status int, body any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(body)
}
```

`FromContext` is `nil` on a route the middleware doesn't wrap, so read it inside the group. `Result` is `nil` when the lookup failed, and then you know nothing about the visitor: signup asks for their email, and checkout lets the order through. `vpndetection.BoolValue` reads a flag your plan doesn't include as `false`, as "Absent is not false" below explains.

We sent both routes the Mullvad server, the home address, and two residential proxy exits from our data, one with `hits` at 21 and one at 2, all through nginx:

| Visitor | `POST /signup` | `POST /checkout` |
|---|---|---|
| Mullvad server | `{"confirm_email":true}` | 403 |
| Home broadband | `{"confirm_email":false}` | 200 |
| Residential proxy exit, `hits` 21 | `{"confirm_email":true}` | 403 |
| Residential proxy exit, `hits` 2 | `{"confirm_email":true}` | 200 |

Each 403 came with `{"error":"Please turn off your VPN or proxy to check out."}`.

`middleware.Matches` is the matcher behind the middleware's own `BlockCondition`. Each map in the list is one way to match, and it names only the fields it cares about. `AnyOf` accepts any of its values, `Gte`, `Gt`, `Lte` and `Lt` compare numbers, and strings match without regard to case. To refuse on every route a middleware wraps, give it a list as `BlockCondition`: with `is_vpn` and `is_tor` in it, the Mullvad server got a 403 with `{"error":"access denied"}` before any handler ran.

echo takes the same middleware through `echo.WrapMiddleware`, and a handler reads the answer from `c.Request()`:

```go
// main.go
package main

import (
	"log"
	"net/http"
	"os"

	"github.com/labstack/echo/v5"
	vpndetectionhttp "github.com/vpndetection-io/sdk-go-nethttp/v2"
	"github.com/vpndetection-io/sdk-go/v5/middleware"
)

func main() {
	guard := vpndetectionhttp.Must(vpndetectionhttp.Options{
		Options: middleware.Options[*http.Request]{
			APIKey:     os.Getenv("VPNDETECTION_API_KEY"),
			IPSelector: vpndetectionhttp.XFFIPSelector(1),
		},
	})

	e := echo.New()
	shop := e.Group("", echo.WrapMiddleware(guard))
	shop.POST("/signup", func(c *echo.Context) error {
		lookup := vpndetectionhttp.FromContext(c.Request().Context())
		masked := lookup.Result == nil || lookup.Result.IsVpn
		answer := map[string]bool{"confirm_email": masked}
		return c.JSON(http.StatusOK, answer)
	})

	log.Fatal(e.Start("127.0.0.1:8000"))
}
```

From the Mullvad server, signup answered `{"confirm_email":true}`, and from the home address `{"confirm_email":false}`. echo's own `IPExtractor` doesn't reach the middleware, which reads the `*http.Request`. With `e.IPExtractor = echo.ExtractIPFromXFFHeader()` and no `IPSelector`, behind the same nginx, `c.RealIP()` was the Mullvad server, and the middleware looked up `127.0.0.1`.

## When a lookup fails

The middleware fails open: when our API is unreachable, your quota is spent or the key is wrong, the handler runs with `Result` as `nil` and the reason in `Err`. With an invalid key, the first app answered:

```json
{
  "error": "vpndetection: unauthorized (HTTP 401): invalid API key",
  "ip": "193.32.248.66",
  "result": null
}
```

With the same key, chi's signup asked the Mullvad visitor for their email, and checkout took their order. `FailClosed: true` refuses instead, which makes any outage of ours an outage of your checkout. Fail open, and ask more of a visitor whose lookup failed, as signup does.

The middleware keeps answers for an hour, and a Go server keeps the middleware: three requests in a row from the Mullvad server made one call to the API. A miss holds the visitor for one request of at most 2.5 seconds. Routes outside the group cost nothing, and `Skip` in the options leaves out anything else, such as static files.

A free key covers 50,000 lookups a month, and paid plans start at 250,000 ([pricing](https://vpndetection.io/pricing)). With a few million distinct visitors a day, download the [VPN IP database](https://vpndetection.io/databases/vpn-ip) and look addresses up locally instead of calling the [API](https://vpndetection.io/api).

## Absent is not false

Without a key, the Mullvad request from the first section answers:

```json
{
  "ip": "193.32.248.66",
  "is_vpn": true,
  "is_bogon": false
}
```

`is_bogon` is the middleware's own field. The rest are `nil` pointers, which mean "not in your plan", where `false` means "checked, and no". `is_vpn` is on every plan, the hosting, Tor, relay and CDN flags start on Starter, the proxy flags on Scale, and `confidence`, `method` and `resproxy.hits` are on Max.

So without a key, chi's checkout still refused the Mullvad server on `is_vpn`, and the proxy exit with 21 hits got through both routes: signup didn't ask for its email, and checkout took its order. `middleware.Matches` says nothing about that. A `BlockCondition` naming `is_tor` does, once:

```
WARN block condition names is_tor, which your plan does not include, so those terms can never match; an absent member means "not in your plan", not "checked, and no"
```

In Node.js, Express's `trust proxy` names the proxies to believe: [How to detect VPN users in Express.js](https://vpndetection.io/blog/detect-vpn-users-express-js).

