Skip to content
VPNDetection

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.

Published 7 min read

On this page
  1. Look up the caller in an interceptor
  2. peer.FromContext is your proxy
  3. Behind a TCP load balancer, use PROXY protocol
  4. Screen some methods, and streams
  5. What a lookup costs, and when it fails
  6. Absent is not false

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. 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: 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. 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 addressThrough nginxStraight to the server
peer.FromContext, the default127.0.0.1193.32.248.66
The first x-forwarded-for entry1.1.1.11.1.1.1
selectors.XFF(1)193.32.248.661.1.1.1
selectors.XFF(1), listening on 127.0.0.1193.32.248.66couldn't connect
realip, trusting 127.0.0.0/8193.32.248.66193.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 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 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:

ListenerBalancer, PROXY offBalancer, PROXY onStraightStraight, forged line
net.Listen alone127.0.0.1connection failed193.32.248.66connection failed
proxyproto.Listener, default policyconnection failed193.32.248.66connection failed1.1.1.1
TrustProxyHeaderFrom the balancerconnection failed193.32.248.66connection failedconnection 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:

CallerSignupDownload
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 deadlineThe call tookIt answered
none2.53 s{"confirmEmail": true}
5 s2.53 s{"confirmEmail": true}
1 s1.03 sDeadlineExceeded

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). With a few million distinct callers 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 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.

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. 1,000 lookups a day per address, answering ip and is_vpn.

curl "https://api.vpndetection.io/146.70.116.130"
{
"ip": "146.70.116.130",
"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.
How to detect VPN users in a gRPC service in Go | VPNDetection