On this page
This tutorial adds VPN detection to a Cowboy app with vpndetection_cowboy, our middleware. By the end, signup asks visitors on a VPN, a residential proxy or Tor to confirm their email, checkout turns them away, and health checks never pay for a lookup. Whether any of it works comes down to the address the middleware looks up, and Cowboy has no trusted-proxy setting. Behind nginx, cowboy_req:peer/1 is nginx, and the selectors that read X-Forwarded-For or CF-Connecting-IP believe whoever sends those headers. Behind a TCP balancer, the address arrives in a PROXY header the middleware can't see. We ran every snippet against the live API on October 21, 2026, with Erlang/OTP 29.1.2, rebar3 3.27.3, Cowboy 2.20.0 and vpndetection_cowboy 2.1.3 on our Erlang client 3.9.0, and every output below is from those runs.
Put the middleware in the chain
rebar3 new release shop
cd shopAdd the middleware to deps in rebar.config:
{deps, [{vpndetection_cowboy, "~> 2.1"}]}.It needs Erlang/OTP 27 or newer and Cowboy 2.13 or newer, and brings Cowboy and our Erlang client, vpndetection, with it. A release takes only the applications your app lists, so add the two to applications in apps/shop/src/shop.app.src:
{applications, [
kernel,
stdlib,
cowboy,
vpndetection_cowboy
]},Without them, the release built and then stopped at boot with undef on vpndetection_cowboy:core/1. 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 app builds the middleware's core when it starts, and names the middleware in the listener's chain:
%% apps/shop/src/shop_app.erl
start(_StartType, _StartArgs) ->
{ok, Core} = vpndetection_cowboy:core(#{
api_key => os:getenv("VPNDETECTION_API_KEY"),
%% Health checks never pay for a lookup.
skip => fun(Req) -> cowboy_req:path(Req) =:= <<"/healthz">> end
}),
Dispatch = cowboy_router:compile([
{'_', [
{"/healthz", health_handler, []},
{"/check", check_handler, []},
{"/signup", signup_handler, []},
{"/checkout", checkout_handler, []}
]}
]),
{ok, _} = cowboy:start_clear(http, [{port, 8080}], #{
env => #{dispatch => Dispatch, vpndetection => Core},
middlewares => [cowboy_router, vpndetection_cowboy, cowboy_handler]
}),
shop_sup:start_link().
stop(_State) ->
ok = cowboy:stop_listener(http).core/1 builds the API client and its answer cache, so it runs once, here: the cache is a process linked to the one running start/2, which lives as long as the application. os:getenv/1 answers false for an unset variable, which the middleware reads as no key. health_handler replies ok, and the other three handlers follow.
The order of middlewares matters. After cowboy_router, a path that no route matches gets its 404 from the router: from a freshly started app, GET /nope cost no lookup, counted at a proxy that logged every call to our API, and with the middleware ahead of the router it cost one. Before cowboy_handler, since that's where your handler runs: with the middleware after it, signup ran first, read undefined and asked nobody to confirm, and no lookup reached the API.
Build the release and start it with your key:
rebar3 release
export VPNDETECTION_API_KEY=...
_build/default/rel/shop/bin/shop foregroundRead the answer in a handler
%% apps/shop/src/check_handler.erl
-module(check_handler).
-export([init/2]).
%% What the middleware looked up, and what came back.
init(Req, State) ->
#{ip := Ip, result := Result, error := Error} =
vpndetection_cowboy:lookup(Req),
Body = json:encode(#{
looked_up => Ip, result => raw(Result), error => why(Error)
}),
Headers = #{<<"content-type">> => <<"application/json">>},
{ok, cowboy_req:reply(200, Headers, Body, Req), State}.
%% The API's answer as it came, or null when the lookup failed.
raw(#{raw := Raw}) -> Raw;
raw(undefined) -> null.
why(#{kind := Kind, message := Message}) -> #{kind => Kind, message => Message};
why(undefined) -> null.vpndetection_cowboy:lookup/1 returns what the middleware found: the address it looked up as ip, the answer as result, and error when the lookup failed. raw is the API's answer as it came. With nothing in front of the app, from 141.98.252.67, a Mullvad server in London from Mullvad's own server list, part of result read:
{
"is_hosting": true,
"is_resproxy": false,
"is_tor": false,
"is_vpn": true,
"vpn": {
"confidence": "high",
"last_seen": "2026-10-21",
"provider": "mullvad"
}
}That's a key on the Max plan. result also carries location and asn: the United Kingdom and AS39351 here. A home broadband address on a US cable ISP came back with every flag false, every detail object empty, and location and asn filled in. A private or reserved address never reaches the API: the middleware answers it in the process.
Which address the middleware looks up
By default it reads cowboy_req:peer/1, which Cowboy's manual describes as "the IP address of the endpoint connecting directly to the server, which may be a gateway or a proxy." We put nginx 1.29.1 in front of the app, in a container of its own at 172.28.5.12 on a private network, with the usual proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for. Through it, the Mullvad server was looked up as 172.28.5.12, a private address the middleware answers itself, so nobody was flagged. The middleware logged that once per address, through logger:warning/1 unless you pass an on_warn fun: "resolved the client address as 172.28.5.12, which is not a public address", with advice to pass an ip_selector. Cowboy has no trusted-proxy setting, so that's the fix.
The README offers two that read headers. vpndetection_selectors:xff(1) reads the first X-Forwarded-For entry from the right, and xff(0) the left-most:
%% The right-most X-Forwarded-For entry, the one nginx appended.
ip_selector => vpndetection_selectors:xff(1),The Mullvad server then sent X-Forwarded-For: 1.1.1.1, through nginx and straight to the app. The address each selector looked up:
ip_selector | Through nginx | Through nginx, forged | Straight, forged |
|---|---|---|---|
| None, the socket peer | nginx | nginx | the visitor |
xff(0) | the visitor | 1.1.1.1 | 1.1.1.1 |
xff(1) | the visitor | the visitor | 1.1.1.1 |
xff(1) in trusted/2, below | the visitor | the visitor | the visitor |
xff(1) held behind nginx, which appends the address it saw, so a forged entry stays to its left. Straight to the app, the only entry was the one the visitor wrote, and xff(1) took it. 1.1.1.1 answers is_hosting and is_cdn but not is_vpn, so a VPN check waves the visitor through. Let only nginx reach the app, or have the selector check who connected: its view of the request carries the socket peer as well as the headers. trusted/2 runs a selector only on a connection from the ranges you list:
%% apps/shop/src/shop_ip.erl
-module(shop_ip).
-export([trusted/2, within/2]).
%% Cowboy has no trusted-proxy setting, so this is one: a selector that
%% runs Selector on a connection from one of Proxies, ranges such as
%% <<"172.28.5.0/24">>, and answers the connecting address on any other.
trusted(Proxies, Selector) ->
fun(View) ->
Peer = (maps:get(framework_ip, View))(),
{ok, Addr} = inet:parse_address(binary_to_list(Peer)),
case within(Addr, Proxies) of
true -> Selector(View);
false -> Peer
end
end.
%% Whether an address tuple is inside one of Ranges.
within(Addr, Ranges) ->
lists:any(fun(Range) -> in_range(Addr, Range) end, Ranges).
in_range(Addr, Range) ->
[Net, Bits] = binary:split(Range, <<"/">>),
{ok, NetAddr} = inet:parse_address(binary_to_list(Net)),
N = binary_to_integer(Bits),
tuple_size(Addr) =:= tuple_size(NetAddr)
andalso prefix(Addr, N) =:= prefix(NetAddr, N).
prefix(Addr, N) ->
Width = case tuple_size(Addr) of 4 -> 8; 8 -> 16 end,
<<Prefix:N/bits, _/bits>> = << <<P:Width>> || P <- tuple_to_list(Addr) >>,
Prefix. %% X-Forwarded-For's right-most entry, on a connection from nginx.
ip_selector => shop_ip:trusted([<<"172.28.5.12/32">>],
vpndetection_selectors:xff(1)),Behind a CDN, the connecting address is the CDN's. From an address in Cloudflare's range, the default looked that address up and found is_cdn, and the README's other selector, vpndetection_selectors:header(<<"CF-Connecting-IP">>), found the visitor. Straight to the app, though, CF-Connecting-IP: 1.1.1.1 was looked up as 1.1.1.1. Given Cloudflare's published ranges, trusted/2 closes that too:
%% CF-Connecting-IP, on a connection from Cloudflare.
ip_selector => shop_ip:trusted(
cloudflare(), vpndetection_selectors:header(<<"CF-Connecting-IP">>)
),%% apps/shop/src/shop_app.erl
%% Cloudflare's ranges, from https://www.cloudflare.com/ips/
cloudflare() ->
[<<"173.245.48.0/20">>, <<"103.21.244.0/22">>, <<"103.22.200.0/22">>,
<<"103.31.4.0/22">>, <<"141.101.64.0/18">>, <<"108.162.192.0/18">>,
<<"190.93.240.0/20">>, <<"188.114.96.0/20">>, <<"197.234.240.0/22">>,
<<"198.41.128.0/17">>, <<"162.158.0.0/15">>, <<"104.16.0.0/13">>,
<<"104.24.0.0/14">>, <<"172.64.0.0/13">>, <<"131.0.72.0/22">>,
<<"2400:cb00::/32">>, <<"2606:4700::/32">>, <<"2803:f800::/32">>,
<<"2405:b500::/32">>, <<"2405:8100::/32">>, <<"2a06:98c0::/29">>,
<<"2c0f:f248::/32">>].From Cloudflare's address, the header still found the Mullvad visitor, and straight to the app, the forged one was looked up as the visitor's own address.
Behind a TCP balancer, PROXY protocol
An L4 balancer, such as an AWS Network Load Balancer or HAProxy in TCP mode, passes the connection through without reading HTTP, so it adds no header. It can send the visitor's address in a PROXY protocol header ahead of the request instead, and Cowboy reads one when the listener sets proxy_header => true, keeping it on the request under proxy_header, its manual says. Our balancer was nginx's stream module, at the same address:
stream {
server {
listen 80;
proxy_pass 172.28.5.11:8080;
proxy_protocol on;
}
}With only proxy_header => true set, the middleware still looked up the balancer, 172.28.5.12: cowboy_req:peer/1 stays the socket's peer. The selector for a PROXY header reads the source address it carries, and believes it only from the balancers you list, as addresses or CIDR blocks. Add it to the core:
%% The PROXY header's source, on a connection from the balancer.
ip_selector =>
vpndetection_selectors:proxy_protocol([<<"172.28.5.12/32">>]),and start the listener with proxy_header => true:
{ok, _} = cowboy:start_clear(http, [{port, 8080}], #{
%% Every connection opens with a PROXY header from the balancer.
proxy_header => true,
env => #{dispatch => Dispatch, vpndetection => Core},
middlewares => [cowboy_router, vpndetection_cowboy, cowboy_handler]
}),Through the balancer, the Mullvad server and the home line were each looked up as themselves. Straight to the app, a request with no PROXY header got no answer: Cowboy closed the connection, and curl reported an empty reply. One that brought its own, curl --haproxy-clientip 1.1.1.1, was looked up as the visitor's own address, since it didn't come from the balancer. Cowboy's security checklist says the option "MUST only be enabled for listeners reachable exclusively via trusted proxies or load balancers".
Decide per route
Signup and checkout read the lookup the middleware left on the request:
%% apps/shop/src/signup_handler.erl
-module(signup_handler).
-export([init/2]).
%% Nobody is refused, but a masked visitor confirms their email first.
init(Req, State) ->
Masked = masked(vpndetection_cowboy:lookup(Req)),
Body = json:encode(#{confirm_email => Masked}),
Headers = #{<<"content-type">> => <<"application/json">>},
{ok, cowboy_req:reply(200, Headers, Body, Req), State}.
%% A VPN, residential proxy or Tor visitor, or one we know nothing about.
masked(#{result := undefined}) -> true;
masked(#{result := #{is_vpn := true}}) -> true;
masked(#{result := #{is_resproxy := true}}) -> true;
masked(#{result := #{is_tor := true}}) -> true;
masked(_) -> false.%% apps/shop/src/checkout_handler.erl
-module(checkout_handler).
-export([init/2]).
-define(REFUSED, <<"Please turn off your VPN or proxy to check out.">>).
init(Req, State) ->
{Status, Body} =
case refuse(vpndetection_cowboy:lookup(Req)) of
true -> {403, #{error => ?REFUSED}};
false -> {200, #{ordered => true}}
end,
Headers = #{<<"content-type">> => <<"application/json">>},
{ok, cowboy_req:reply(Status, Headers, json:encode(Body), Req), State}.
%% A VPN, Tor, or a residential proxy exit seen 5 times or more.
refuse(#{result := #{is_vpn := true}}) -> true;
refuse(#{result := #{is_tor := true}}) -> true;
refuse(#{result := #{resproxy := #{hits := Hits}}}) -> Hits >= 5;
refuse(_) -> false.A map pattern matches only a key that's there. Every flag but is_vpn is absent from a result when your plan doesn't include it, so a flag you can't see falls through to the last clause, false. masked/1 answers true when the lookup failed, since then you know nothing about the visitor.
We sent both routes the Mullvad server and the home line through nginx, then two residential proxy exits from our data as nginx hands them over, one with hits at 25 and one at 1, with trusted/2 reading X-Forwarded-For:
| Visitor | POST /signup | POST /checkout |
|---|---|---|
| Mullvad server | {"confirm_email":true} | 403 |
| Home broadband | {"confirm_email":false} | 200 |
Residential proxy exit, hits 25 | {"confirm_email":true} | 403 |
Residential proxy exit, hits 1 | {"confirm_email":true} | 200 |
Each 403 came with {"error":"Please turn off your VPN or proxy to check out."}.
To refuse on every route the middleware covers, before any handler runs, give core/1 a block_condition, written in the shape of a result:
%% Refuse a VPN, Tor, or a residential proxy exit seen 5 times or more.
block_condition => [
#{is_vpn => true},
#{is_tor => true},
#{resproxy => #{hits => #{gte => 5}}}
],With it, the Mullvad server and the exit with 25 hits got a 403 with {"error":"access denied"} on /check and /signup alike, and the home line and the exit with 1 went through. An on_blocked fun replaces that answer.
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 undefined and the reason in error. With the app's api.vpndetection.io pointed at an address that refuses connections, signup answered in about 3 ms, the error's kind network. Pointed at one that never answers, signup took 2.5 seconds, the middleware's default timeout, and didn't retry. With an invalid key, error read {"kind":"unauthorized","message":"invalid API key"}. Each time, signup asked the Mullvad visitor for their email and checkout took their order. fail_closed => true refuses instead.
One core keeps answers for an hour, up to 10,000 addresses: after a restart, 50 signups from the Mullvad server cost one lookup, and 20 requests to /healthz, which the skip in start/2 claims, cost none. A free key covers 50,000 lookups a month, and paid plans start at 250,000 (pricing). With a few million distinct visitors a day, download the VPN IP database and look addresses up locally instead of calling the API.
Absent is not false
Started without a key, the app logged once that "api_key is false, which os:getenv/1 answers for an unset variable, so every lookup goes out with no key", and the Mullvad server's result read:
{
"ip": "141.98.252.67",
"is_vpn": true
}Every other field is missing, which means "not in your plan", not "checked, and no". maps:get(is_hosting, Result, undefined) tells the two apart where it matters. 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, checkout still refused the Mullvad server on is_vpn, while the exit with 25 hits signed up without confirming an email and placed its order. The block_condition above warned about that once: it "names is_tor, resproxy, which your plan does not include, so those terms can never match".
Without Cowboy, our Erlang client, vpndetection, looks addresses up on its own.


