# How to detect VPN users in Cowboy (Erlang)

Add VPN detection to a Cowboy app with vpndetection_cowboy, and hand it the visitor's real address behind nginx, Cloudflare or a PROXY protocol balancer.

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

```bash
rebar3 new release shop
cd shop
```

Add [the middleware](https://hex.pm/packages/vpndetection_cowboy) to `deps` in `rebar.config`:

```erlang
{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`](https://hex.pm/packages/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`:

```erlang
    {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](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 app builds the middleware's core when it starts, and names the middleware in the listener's chain:

```erlang
%% 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:

```bash
rebar3 release
export VPNDETECTION_API_KEY=...
_build/default/rel/shop/bin/shop foreground
```

## Read the answer in a handler

```erlang
%% 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](https://mullvad.net/en/servers), part of `result` read:

```json
{
  "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](https://ninenines.eu/docs/en/cowboy/2.20/manual/cowboy_req.peer/) 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](https://github.com/vpndetection-io/sdk-erlang-cowboy) 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:

```erlang
        %% 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:

```erlang
%% 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.
```

```erlang
        %% 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](https://www.cloudflare.com/ips/), `trusted/2` closes that too:

```erlang
        %% CF-Connecting-IP, on a connection from Cloudflare.
        ip_selector => shop_ip:trusted(
            cloudflare(), vpndetection_selectors:header(<<"CF-Connecting-IP">>)
        ),
```

```erlang
%% 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](https://ninenines.eu/docs/en/cowboy/2.20/manual/cowboy_http/). Our balancer was nginx's stream module, at the same address:

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

```erlang
        %% 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`:

```erlang
    {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](https://ninenines.eu/docs/en/cowboy/2.20/guide/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:

```erlang
%% 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.
```

```erlang
%% 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:

```erlang
        %% 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](https://vpndetection.io/pricing)). With a few million distinct 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

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:

```json
{
  "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`](https://docs.vpndetection.io/integrations/erlang), looks addresses up on its own.

