On this page
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
go get github.com/vpndetection-io/sdk-go/v5 google.golang.org/grpcThe 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:
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;
}protoc --go_out=. --go_opt=module=example.com/signup \
--go-grpc_out=. --go-grpc_opt=module=example.com/signup auth.protoWe 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:
// 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().
// 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:
{
"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:
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:
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:
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:
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:
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:
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:
// 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). 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:
{"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.


