# How to detect VPN users in a gRPC service in Go

A Go gRPC interceptor that screens callers for VPNs and proxies, and which client-address fixes behind nginx or a TCP balancer believe a forged address.

This tutorial screens calls to a Go gRPC service for VPNs, residential proxies and Tor, with a unary and a stream interceptor built on our [Go client library](https://docs.vpndetection.io/integrations/golang). No HTTP middleware runs in a gRPC server, so the interceptor finds the caller's address itself, and the obvious source, `peer.FromContext`, is whatever opened the connection: behind nginx or a load balancer, that's the proxy. The fixes each trust something different, and three of the eight setups we tested looked up an address the caller made up. We ran every snippet against the live API on October 22, 2026, with Go 1.27.3, grpc-go 1.84.0, `sdk-go` 5.9.0, go-grpc-middleware 2.3.4 and go-proxyproto 0.16.0, and every output below is from those runs.

## Look up the caller in an interceptor

```bash
go get github.com/vpndetection-io/sdk-go/v5 google.golang.org/grpc
```

The library 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 service has one unary method, `Signup`, and one that streams, `Download`:

```protobuf
syntax = "proto3";

package auth;

option go_package = "example.com/signup/authpb";

service Auth {
  rpc Signup(SignupRequest) returns (SignupReply);
  rpc Download(DownloadRequest) returns (stream Chunk);
}

message SignupRequest {
  string email = 1;
}

message SignupReply {
  bool confirm_email = 1;
}

message DownloadRequest {
  string name = 1;
}

message Chunk {
  bytes data = 1;
}
```

```bash
protoc --go_out=. --go_opt=module=example.com/signup \
  --go-grpc_out=. --go-grpc_opt=module=example.com/signup auth.proto
```

We generated the code with protoc 36.2, protoc-gen-go 1.36.12 and protoc-gen-go-grpc 1.6.2. The interceptor is built on `middleware.Core`, the framework-agnostic half of our net/http and Gin middleware. It takes any request type, and here that's the call's `context.Context`, where gRPC keeps both the caller's metadata and the connection it came in on:

```go
// guard.go
package main

import (
	"context"
	"net"
	"strings"

	"github.com/vpndetection-io/sdk-go/v5/middleware"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/peer"
	"google.golang.org/grpc/status"
)

// view shows the SDK's middleware a call's metadata and its socket peer.
func view(ctx context.Context) middleware.RequestView {
	md, _ := metadata.FromIncomingContext(ctx)
	return middleware.RequestView{
		Header: func(name string) string {
			return strings.Join(md.Get(name), ",")
		},
		FrameworkIP: func() string {
			p, ok := peer.FromContext(ctx)
			if !ok {
				return ""
			}
			host, _, _ := net.SplitHostPort(p.Addr.String())
			return host
		},
	}
}

var selectors = middleware.BindSelectors(view)

type lookupKey struct{}

// LookupFrom is the verdict on the caller, nil on a method the guard skips.
func LookupFrom(ctx context.Context) *middleware.Lookup {
	lookup, _ := ctx.Value(lookupKey{}).(*middleware.Lookup)
	return lookup
}

// Guard looks up the caller of each method it screens.
type Guard struct {
	core    *middleware.Core[context.Context]
	methods map[string]bool
}

func NewGuard(
	options middleware.Options[context.Context], methods ...string,
) (*Guard, error) {
	core, err := middleware.New(options, selectors.Default)
	if err != nil {
		return nil, err
	}
	g := &Guard{core: core, methods: map[string]bool{}}
	for _, method := range methods {
		g.methods[method] = true
	}
	return g, nil
}

func (g *Guard) check(ctx context.Context, method string) (context.Context, error) {
	if !g.methods[method] {
		return ctx, nil
	}
	lookup, err := g.core.Evaluate(ctx, ctx)
	if err != nil {
		return nil, status.Error(codes.Internal, err.Error())
	}
	if lookup == nil {
		return ctx, nil
	}
	if lookup.Blocked {
		return nil, status.Error(codes.PermissionDenied, "access denied")
	}
	return context.WithValue(ctx, lookupKey{}, lookup), nil
}

func (g *Guard) Unary(
	ctx context.Context, req any,
	info *grpc.UnaryServerInfo, handler grpc.UnaryHandler,
) (any, error) {
	ctx, err := g.check(ctx, info.FullMethod)
	if err != nil {
		return nil, err
	}
	return handler(ctx, req)
}

func (g *Guard) Stream(
	srv any, stream grpc.ServerStream,
	info *grpc.StreamServerInfo, handler grpc.StreamHandler,
) error {
	ctx, err := g.check(stream.Context(), info.FullMethod)
	if err != nil {
		return err
	}
	return handler(srv, &screenedStream{stream, ctx})
}

// screenedStream is a stream whose context carries the verdict.
type screenedStream struct {
	grpc.ServerStream
	ctx context.Context
}

func (s *screenedStream) Context() context.Context { return s.ctx }
```

`view` is the only part that knows about gRPC: `Header` reads the incoming metadata, whose keys gRPC lowercases, and `FrameworkIP` is the socket peer. `BindSelectors` turns it into the same address selectors the HTTP middleware has. `Evaluate` looks the caller up with a 2.5-second timeout and no retries, answers a private address in the process, and fails open: when the lookup fails, `Result` is `nil` and the reason is in `Err`. The guard screens only the methods you name, by gRPC's full method name, and puts the answer on the call's context. `Stream` wraps the stream, because a stream handler reads its context from `stream.Context()`.

```go
// main.go
package main

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

	"example.com/signup/authpb"
	"github.com/vpndetection-io/sdk-go/v5/middleware"
	"google.golang.org/grpc"
)

type auth struct {
	authpb.UnimplementedAuthServer
}

func (auth) Signup(
	ctx context.Context, req *authpb.SignupRequest,
) (*authpb.SignupReply, error) {
	lookup := LookupFrom(ctx)
	verdict, _ := json.Marshal(lookup.Result)
	log.Printf("signup from %s: %s %v", lookup.IP, verdict, lookup.Err)
	masked := lookup.Result == nil || lookup.Result.IsVpn
	return &authpb.SignupReply{ConfirmEmail: masked}, nil
}

func main() {
	guard, err := NewGuard(middleware.Options[context.Context]{
		APIKey: os.Getenv("VPNDETECTION_API_KEY"),
	}, authpb.Auth_Signup_FullMethodName)
	if err != nil {
		log.Fatal(err)
	}

	server := grpc.NewServer(
		grpc.UnaryInterceptor(guard.Unary),
		grpc.StreamInterceptor(guard.Stream),
	)
	authpb.RegisterAuthServer(server, auth{})

	listener, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatal(err)
	}
	log.Fatal(server.Serve(listener))
}
```

We called `Signup` with grpcurl from `193.32.248.66`, a Mullvad server in Berlin from [Mullvad's own server list](https://mullvad.net/en/servers). It answered `{"confirmEmail": true}`, and the server logged this lookup, in part:

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

That's a key on the Max plan. The answer also has `is_relay`, `is_cdn`, `is_dcproxy` and `is_mobproxy`, a detail object under each flag, and `location` and `asn`: Germany and AS39351 here. From a home broadband address on a US cable ISP, every flag was `false`, `location` and `asn` were filled in, and `Signup` answered `{"confirmEmail": false}`.

## peer.FromContext is your proxy

We put nginx 1.29.1 in front of the server on the same host, proxying gRPC with the usual header:

```nginx
server {
    listen 50050;
    http2 on;
    location / {
        grpc_pass grpc://127.0.0.1:50051;
        grpc_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
```

It listens without TLS to keep the test short. The Mullvad caller then sent `x-forwarded-for: 1.1.1.1` as metadata of its own, with grpcurl's `-H`, through nginx and straight to the server. The address each setup looked up:

| Client address | Through nginx | Straight to the server |
|---|---|---|
| `peer.FromContext`, the default | `127.0.0.1` | `193.32.248.66` |
| The first `x-forwarded-for` entry | `1.1.1.1` | `1.1.1.1` |
| `selectors.XFF(1)` | `193.32.248.66` | `1.1.1.1` |
| `selectors.XFF(1)`, listening on `127.0.0.1` | `193.32.248.66` | couldn't connect |
| `realip`, trusting `127.0.0.0/8` | `193.32.248.66` | `193.32.248.66` |

With the default, every call through nginx is nginx, a private address the library answers locally, so nobody is ever flagged. The one sign is a warning in the server's log: "resolved the client address as 127.0.0.1, which is not a public address".

The first-entry row is the header read by hand:

```go
func firstForwarded(ctx context.Context) string {
	md, _ := metadata.FromIncomingContext(ctx)
	if xff := md.Get("x-forwarded-for"); len(xff) > 0 {
		return strings.TrimSpace(strings.Split(xff[0], ",")[0])
	}
	return selectors.Default(ctx)
}
```

nginx appends the address it saw, so the caller's own entry comes first, and this looks up whatever they wrote. `1.1.1.1` answers `is_cdn` and `is_hosting` but not `is_vpn`, so a VPN check waves them through.

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

```go
	guard, err := NewGuard(middleware.Options[context.Context]{
		APIKey:     os.Getenv("VPNDETECTION_API_KEY"),
		IPSelector: selectors.XFF(1),
	}, authpb.Auth_Signup_FullMethodName)
	// ...
	listener, err := net.Listen("tcp", "127.0.0.1:50051")
```

Straight to the server, the caller's metadata has nothing after their own entry, so `XFF(1)` read `1.1.1.1` there. Listening on `127.0.0.1`, only nginx on the same host can connect, and grpcurl from the Mullvad server couldn't. 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 call passes.

go-grpc-middleware's [`realip`](https://pkg.go.dev/github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/realip) checks the socket first: it reads the header only on a call from a peer you name, and from the right. It got both columns right with the server listening on every address. Chain it in front of the guard, and read its answer as the selector:

```go
func main() {
	guard, err := NewGuard(middleware.Options[context.Context]{
		APIKey: os.Getenv("VPNDETECTION_API_KEY"),
		IPSelector: func(ctx context.Context) string {
			if ip, ok := realip.FromContext(ctx); ok {
				return ip.String()
			}
			return ""
		},
	}, authpb.Auth_Signup_FullMethodName)
	if err != nil {
		log.Fatal(err)
	}

	proxies := []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}
	headers := []string{realip.XForwardedFor}
	server := grpc.NewServer(
		grpc.ChainUnaryInterceptor(
			realip.UnaryServerInterceptor(proxies, headers),
			guard.Unary,
		),
		grpc.ChainStreamInterceptor(
			realip.StreamServerInterceptor(proxies, headers),
			guard.Stream,
		),
	)
```

`proxies` can be a subnet, so this holds with the proxy on another machine too, and `realip.UnaryServerInterceptorOpts` takes `WithTrustedProxiesCount` for a chain of more than one.

## Behind a TCP load balancer, use PROXY protocol

A TCP load balancer that connects to your server from its own address, as nginx's `stream` module does, adds no header: `peer.FromContext` is the balancer, and there's nothing else to read. PROXY protocol fixes that. The balancer opens each connection with one line naming the caller, and the server reads it before gRPC does. HAProxy, nginx and AWS's Network Load Balancer can all send it. We turned it on in nginx:

```nginx
stream {
    server {
        listen 50053;
        proxy_pass 127.0.0.1:50051;
        proxy_protocol on;
    }
}
```

On the server, [go-proxyproto](https://github.com/pires/go-proxyproto) wraps the listener, and its policy names who may send the line. The end of the first `main`, changed:

```go
	listener, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatal(err)
	}
	// Only the balancer may say who the caller is.
	balancer := net.ParseIP("127.0.0.1")
	log.Fatal(server.Serve(&proxyproto.Listener{
		Listener:   listener,
		ConnPolicy: proxyproto.TrustProxyHeaderFrom(balancer),
	}))
```

We called it from the Mullvad server through the balancer with PROXY protocol off and on, straight to the server, and straight with a forged line: curl's `--haproxy-clientip 1.1.1.1` opens the connection with a PROXY line naming any address you like. The address each listener looked up:

| Listener | Balancer, PROXY off | Balancer, PROXY on | Straight | Straight, forged line |
|---|---|---|---|---|
| `net.Listen` alone | `127.0.0.1` | connection failed | `193.32.248.66` | connection failed |
| `proxyproto.Listener`, default policy | connection failed | `193.32.248.66` | connection failed | `1.1.1.1` |
| `TrustProxyHeaderFrom` the balancer | connection failed | `193.32.248.66` | connection failed | connection failed |

PROXY protocol has to be on at both ends at once: a balancer sending it to a plain listener, or a wrapped listener reached without it, failed every connection. go-proxyproto's default requires the line and believes it from anyone, so a caller who reaches the server's port directly is whoever they say they are. `TrustProxyHeaderFrom` takes the line only from the balancer's address and drops every other connection, and `TrustProxyHeaderFromRanges` takes a subnet.

## Screen some methods, and streams

Most methods don't care who's on a VPN. Here `Signup` asks a masked caller to confirm their email, `Download` refuses them, and the health check isn't screened at all:

```go
// main.go
package main

import (
	"context"
	"log"
	"net"
	"os"

	"example.com/signup/authpb"
	vpndetection "github.com/vpndetection-io/sdk-go/v5"
	"github.com/vpndetection-io/sdk-go/v5/middleware"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/health"
	healthpb "google.golang.org/grpc/health/grpc_health_v1"
	"google.golang.org/grpc/status"
)

// Downloads refuse a VPN, Tor, or a residential proxy exit seen 5 times or more.
var refuseDownload = middleware.Conditions{
	{"is_vpn": true},
	{"is_tor": true},
	{"is_resproxy": true, "resproxy": middleware.BlockCondition{
		"hits": middleware.Gte(5),
	}},
}

type auth struct {
	authpb.UnimplementedAuthServer
}

// Nobody is refused, but a masked caller confirms their email first.
func (auth) Signup(
	ctx context.Context, req *authpb.SignupRequest,
) (*authpb.SignupReply, error) {
	result := LookupFrom(ctx).Result
	masked := result == nil ||
		result.IsVpn ||
		vpndetection.BoolValue(result.IsResproxy) ||
		vpndetection.BoolValue(result.IsTor)
	return &authpb.SignupReply{ConfirmEmail: masked}, nil
}

func (auth) Download(
	req *authpb.DownloadRequest, stream authpb.Auth_DownloadServer,
) error {
	result := LookupFrom(stream.Context()).Result
	if result != nil && middleware.Matches(refuseDownload, result) {
		msg := "Please turn off your VPN or proxy to download."
		return status.Error(codes.PermissionDenied, msg)
	}
	for _, part := range []string{"first chunk", "last chunk"} {
		if err := stream.Send(&authpb.Chunk{Data: []byte(part)}); err != nil {
			return err
		}
	}
	return nil
}

func main() {
	guard, err := NewGuard(middleware.Options[context.Context]{
		APIKey:     os.Getenv("VPNDETECTION_API_KEY"),
		IPSelector: selectors.XFF(1),
	}, authpb.Auth_Signup_FullMethodName, authpb.Auth_Download_FullMethodName)
	if err != nil {
		log.Fatal(err)
	}

	server := grpc.NewServer(
		grpc.UnaryInterceptor(guard.Unary),
		grpc.StreamInterceptor(guard.Stream),
	)
	authpb.RegisterAuthServer(server, auth{})
	healthpb.RegisterHealthServer(server, health.NewServer())

	listener, err := net.Listen("tcp", "127.0.0.1:50051")
	if err != nil {
		log.Fatal(err)
	}
	log.Fatal(server.Serve(listener))
}
```

We called both methods through nginx from the Mullvad server, the home address, and two residential proxy exits from our data, one with `hits` at 25 and one at 1:

| Caller | `Signup` | `Download` |
|---|---|---|
| Mullvad server | `{"confirmEmail": true}` | `PermissionDenied` |
| Home broadband | `{"confirmEmail": false}` | two chunks |
| Residential proxy exit, `hits` 25 | `{"confirmEmail": true}` | `PermissionDenied` |
| Residential proxy exit, `hits` 1 | `{"confirmEmail": true}` | two chunks |

Each `PermissionDenied` came with "Please turn off your VPN or proxy to download." `LookupFrom` returns `nil` on a method the guard doesn't screen, so read it only in one it does. `Result` is `nil` when the lookup failed, and then you know nothing about the caller: `Signup` asks for their email, and `Download` lets them through. `vpndetection.BoolValue` reads a flag your plan doesn't include as `false`, as "Absent is not false" below explains.

`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. To refuse every screened method before a handler runs, put the list in the options as `BlockCondition`: with `{{"is_vpn": true}}` there, the Mullvad server's `Signup` got `PermissionDenied` with "access denied", and the home address's went through.

## What a lookup costs, and when it fails

We counted the server's calls to our API at a logging proxy in front of it. Three health checks made none. Three `Signup` calls and a `Download` from the Mullvad server made one: the library keeps answers for an hour, per address, and a Go server keeps its client. The home address made the second.

With an invalid key, the first server logged `vpndetection: unauthorized (HTTP 401): invalid API key` for each call, and `Signup` asked the Mullvad caller for their email. Then we pointed the server at an API address that never answers:

| Caller's deadline | The call took | It answered |
|---|---|---|
| none | 2.53 s | `{"confirmEmail": true}` |
| 5 s | 2.53 s | `{"confirmEmail": true}` |
| 1 s | 1.03 s | `DeadlineExceeded` |

gRPC sends the caller's deadline with the call, and `Evaluate` takes its 2.5 seconds from the call's context, so a lookup never holds a call past either. With a 1-second deadline, the lookup ended with `context canceled` when the call did. `Timeout` in the options sets the 2.5 seconds, and `FailClosed: true` refuses a caller whose lookup failed, which makes any outage of ours an outage of your signups. Fail open, and ask more of a caller you know nothing about, as `Signup` does.

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 callers 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 first server logged this for the Mullvad server:

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

`is_bogon` is the library's own field. The rest are `nil` pointers, which mean "not in your plan", where `false` means "checked, and no". `is_vpn` comes back without a key, a free key adds the Tor and relay flags, `location` and `asn`, and every paid plan answers every field.

So without a key, `Download` still refused the Mullvad server on `is_vpn`, and the proxy exit with 25 hits got through both methods: `Signup` didn't ask for its email, and `Download` sent it both chunks. `middleware.Matches` says nothing about a field it can't see, so check your plan covers every field your rules name.

For an HTTP server 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).

