On this page
This tutorial adds VPN detection to a Gin app with sdk-go-gin, our official middleware. 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, Gin has to be told which proxies to trust: out of the box it trusts every address, so c.ClientIP(), the address the middleware looks up, is whatever the visitor writes in X-Forwarded-For, with or without a proxy in front. We ran every snippet against the live API on October 12, 2026, with Go 1.27.2, Gin 1.12.0 and sdk-go-gin 2.1.13 on sdk-go 5.5.2, and every output below is from those runs.
Look up the routes that need it
go get github.com/vpndetection-io/sdk-go-gin/v2The 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.
The middleware goes on a route group rather than the whole engine, so only the routes in it cost a lookup:
// main.go
package main
import (
"net/http"
"os"
"github.com/gin-gonic/gin"
vpndetectiongin "github.com/vpndetection-io/sdk-go-gin/v2"
vpndetection "github.com/vpndetection-io/sdk-go/v5"
"github.com/vpndetection-io/sdk-go/v5/middleware"
)
func main() {
r := gin.Default()
r.SetTrustedProxies([]string{"172.28.16.10"})
detect := vpndetectiongin.Must(vpndetectiongin.Options{
Options: middleware.Options[*gin.Context]{
APIKey: os.Getenv("VPNDETECTION_API_KEY"),
},
})
auth := r.Group("/auth", detect)
auth.GET("/whoami", whoami)
auth.POST("/signup", signup)
auth.POST("/checkout", checkout)
r.Run(":8080")
}SetTrustedProxies names the nginx in front of our test app; the next section is about that line, and leaving it out is the mistake this post is about. vpndetectiongin.FromContext() reads the answer in a handler:
func whoami(c *gin.Context) {
lookup := vpndetectiongin.FromContext(c)
var reason any
if lookup.Err != nil {
reason = lookup.Err.Error()
}
c.JSON(http.StatusOK, gin.H{
"ip": lookup.IP,
"result": lookup.Result,
"error": reason,
})
}Nothing is blocked: the middleware looks the visitor up and hands the answer on. We sent a request to /auth/whoami from 193.32.248.66, a Mullvad server in Berlin from Mullvad's own server list. Part of what came back under result:
{
"is_hosting": true,
"is_resproxy": false,
"is_tor": false,
"is_vpn": true,
"vpn": {
"confidence": "high",
"last_seen": "2026-10-11",
"method": "scan",
"provider": "mullvad"
}
}That's a key on the Max plan, and Go writes the keys in alphabetical order. The answer also carries is_relay, is_cdn, is_dcproxy and is_mobproxy, each with a detail object. In code, read the fields, res.IsVpn and res.Vpn. 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 itself, with IsBogon set.
Tell Gin which proxies to trust
c.ClientIP() reads X-Forwarded-For from the right, skips the proxies Gin trusts, and stops at the first address it doesn't. Out of the box Gin trusts every address, so it never stops, and returns the left-most entry: the one the visitor wrote.
We put nginx 1.29.1 in front of the app, in a container of its own, at 172.28.16.10, 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. The address the middleware looked up:
SetTrustedProxies | Through nginx | Straight to the app |
|---|---|---|
| not called | 1.1.1.1 | 1.1.1.1 |
nil | 172.28.16.10 | 193.32.248.66 |
[]string{"172.28.16.10"} | 193.32.248.66 | 193.32.248.66 |
Not called, a visitor chooses the address you look up, and needs no proxy to do it: straight to the app, one header did. 1.1.1.1 answers is_hosting and is_cdn, not is_vpn, so a VPN check waves them through. Gin says so when it starts in debug mode:
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.
With GIN_MODE=release, as in production, it printed nothing, and Gin's own request log recorded the visitor as 1.1.1.1 too.
With nothing in front of the app, call SetTrustedProxies(nil), and ClientIP() is the address that connected. Behind a proxy, nil makes every visitor the proxy: through nginx, that's a private address the middleware answers itself, so nobody is ever flagged. The middleware logs it, once:
WARN resolved the client address as 172.28.16.10, 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.
So name the proxy's own address, or its subnet, as main.go does. An AWS load balancer's addresses change, but it sends requests to your servers from private addresses in your VPC, so trust the VPC's range, such as 10.0.0.0/16.
Behind Cloudflare, Gin has its own setting, r.TrustedPlatform = gin.PlatformCloudflare, which makes ClientIP() read the CF-Connecting-IP header. It reads that header from whoever sends it. From a Cloudflare address, with the headers Cloudflare adds for the Mullvad visitor, it looked up 193.32.248.66; straight to the app, with CF-Connecting-IP: 1.1.1.1, it looked up 1.1.1.1. Cloudflare publishes its ranges, so trust those instead. Neither list ends in a newline, hence the echos:
{ curl -s https://www.cloudflare.com/ips-v4; echo
curl -s https://www.cloudflare.com/ips-v6; echo; } > cloudflare-ips.txtcloudflare, err := os.ReadFile("cloudflare-ips.txt")
if err != nil {
log.Fatal(err)
}
r.SetTrustedProxies(strings.Fields(string(cloudflare)))That needs log and strings imported. On October 12 the file held 22 ranges; fetch them again when you deploy. From the Cloudflare address it looked up 193.32.248.66, and straight to the app, with the forged headers, it looked up 193.32.248.66 too.
Decide signup and checkout
Signup reads the answer to decide what to ask; checkout refuses:
// Refused at checkout: a VPN, Tor, or a proxy exit seen 5 times or more.
var refuse = middleware.Conditions{
{"is_vpn": true},
{"is_tor": true},
{
"is_resproxy": true,
"resproxy": middleware.BlockCondition{"hits": middleware.Gte(5)},
},
}
// Nobody is refused, but a masked visitor confirms their email first.
func signup(c *gin.Context) {
res := vpndetectiongin.FromContext(c).Result
masked := res == nil || res.IsVpn ||
vpndetection.BoolValue(res.IsResproxy) ||
vpndetection.BoolValue(res.IsTor)
c.JSON(http.StatusOK, gin.H{"confirm_email": masked})
}
func checkout(c *gin.Context) {
res := vpndetectiongin.FromContext(c).Result
if res != nil && middleware.Matches(refuse, res) {
msg := "Please turn off your VPN or proxy to check out."
c.JSON(http.StatusForbidden, gin.H{"error": msg})
return
}
c.JSON(http.StatusOK, gin.H{"ordered": true})
}Result is nil when the lookup failed, and then you know nothing about the visitor: signup asks for their email, and checkout takes the order. IsResproxy and IsTor are pointers, nil when your plan doesn't include the field, which vpndetection.BoolValue reads as false; "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 /auth/signup | POST /auth/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, keyed by the names the API uses. Each condition in the list is one way to match, and it names only the fields it cares about. middleware.AnyOf() accepts any of its values, Gte, Gt, Lte and Lt compare numbers, and strings match without regard to case.
To refuse everything in the group, give the middleware the condition instead:
Options: middleware.Options[*gin.Context]{
APIKey: os.Getenv("VPNDETECTION_API_KEY"),
BlockCondition: middleware.Conditions{{"is_vpn": true}, {"is_tor": true}},
},The Mullvad server then got a 403 with {"error":"access denied"} on /auth/whoami and /auth/signup, and the home address got through. Routes outside the group never see the middleware.
When a lookup fails
The middleware fails open: when our API is unreachable, your quota is spent or the key is wrong, the request goes on with Result as nil and the reason in Err. With an invalid key, /auth/whoami answered:
{
"error": "vpndetection: unauthorized (HTTP 401): invalid API key",
"ip": "193.32.248.66",
"result": null
}With the same key, signup asked the Mullvad visitor for their email, and checkout took their order. FailClosed: true refuses every request whose lookup failed, which makes any outage of ours an outage of your app. Fail open, and ask more of a visitor whose lookup failed, as signup does.
The middleware keeps answers for an hour in memory, for as long as the process runs. 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.
A free key covers 50,000 lookups a month, and paid plans start at 250,000 (pricing). With a few million 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 came back with this result:
{
"ip": "193.32.248.66",
"is_vpn": true,
"is_bogon": false
}The other fields aren't in the answer, so res.IsHosting, res.IsTor and res.IsResproxy are nil, which means "not in your plan", where a pointer to 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, 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. With is_tor in its BlockCondition, the middleware logged 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"
Express has the same setting under another name, trust proxy, in How to detect VPN users in Express.js.


