Skip to content
VPNDetection

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.

Published 6 min read

On this page
  1. Look up every request
  2. Read X-Forwarded-For from the right
  3. Decide per route in chi and echo
  4. When a lookup fails
  5. Absent is not false

This tutorial adds VPN detection to a Go server with sdk-go-nethttp, 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: 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. 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 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, 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 addressThrough nginxStraight to the app
r.RemoteAddr, the default127.0.0.1193.32.248.66
ProxyTracer's first entry1.1.1.11.1.1.1
xff.Default()1.1.1.11.1.1.1
xff allowing only 127.0.0.0/81.1.1.1193.32.248.66
XFFIPSelector(1)193.32.248.661.1.1.1
XFFIPSelector(1), listening on 127.0.0.1193.32.248.66refused

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

VisitorPOST /signupPOST /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). With a few million distinct visitors a day, download the VPN IP database and look addresses up locally instead of calling the 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.

Written by

  • Uman Shahzad

    Founder & CEO

    IP intelligence data expert for over 8 years, former lead engineer at IPinfo, Uman has pioneered many systems and practices used by IP data companies to operate at scale, provide high-quality data and offer the best developer experiences.

Get new posts by email

Or follow along in a feed reader.

RSS feed

Try it on your own traffic.

The detection these posts describe is one API call away, with SDKs for every major language.

00 · No key

Start without signing up.

Paste it into a terminal. No account needed. 1k daily allowance per user, answering ip and is_vpn.

curl "https://api.vpndetection.io/45.83.91.1"
{
"ip": "45.83.91.1",
"is_vpn": true
}
01 · Free key

Sign up and get 50,000 requests a month.

No card and no sales call: the key is issued on signup.
Every request flags VPN IPs.
Upgrade only when you outgrow the allowance.