# How to detect VPN users in Gin (Go)

Add VPN detection to a Gin app with sdk-go-gin: Gin trusts every proxy by default, so set SetTrustedProxies, then check signup and checkout.

This tutorial adds VPN detection to a Gin app with [`sdk-go-gin`](https://docs.vpndetection.io/integrations/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

```bash
go get github.com/vpndetection-io/sdk-go-gin/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.

The middleware goes on a route group rather than the whole engine, so only the routes in it cost a lookup:

```go
// 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:

```go
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](https://mullvad.net/en/servers). Part of what came back under `result`:

```json
{
  "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](https://docs.aws.amazon.com/elasticloadbalancing/latest/userguide/how-elastic-load-balancing-works.html) 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](https://www.cloudflare.com/ips/), so trust those instead. Neither list ends in a newline, hence the `echo`s:

```bash
{ curl -s https://www.cloudflare.com/ips-v4; echo
  curl -s https://www.cloudflare.com/ips-v6; echo; } > cloudflare-ips.txt
```

```go
cloudflare, 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:

```go
// 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:

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

```json
{
  "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](https://vpndetection.io/pricing)). With a few million 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 came back with this `result`:

```json
{
  "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](https://vpndetection.io/blog/detect-vpn-users-express-js).

