On this page
This tutorial adds VPN detection to a Phoenix app with vpndetection, our Erlang client, which Elixir calls as :vpndetection. We publish no Phoenix package, so a plug of your own does the lookup. By the end, signup asks visitors on a VPN, a residential proxy or Tor to confirm their email, checkout turns them away, and only the routes under /auth pay for a lookup. Two things decide whether any of it works: where the client starts, since its cache is a process, and which address the plug hands it, since behind a proxy conn.remote_ip is the proxy and Plug.RewriteOn takes whatever address the visitor writes in X-Forwarded-For. We ran every snippet against the live API on October 21, 2026, with Elixir 1.20.4 on Erlang/OTP 29.1.2, Phoenix 1.8.16, Bandit 1.13.1 and vpndetection 3.9.0, and every output below is from those runs.
Start one client for the whole app
mix phx.new shop --no-ecto
cd shopNothing here needs a database. Add the client to deps in mix.exs and run mix deps.get:
{:vpndetection, "~> 3.9"}It needs Erlang/OTP 27 or newer, and nothing outside OTP. 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 reads the key when it boots, in config/runtime.exs:
config :shop, :vpndetection_api_key, System.get_env("VPNDETECTION_API_KEY")A client caches answers, and its cache is a process linked to whoever built the client. So the app builds one, once, in a process of its own under its supervisor:
# lib/shop/vpn_detection.ex
defmodule Shop.VPNDetection do
@moduledoc "Starts the VPNDetection client once, and owns its cache."
use GenServer
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
# Every request reads the client here, without a message to this process.
def lookup(ip), do: :vpndetection.lookup(:persistent_term.get(__MODULE__), ip)
@impl true
def init(_opts) do
client =
:vpndetection.new(%{
api_key: Application.get_env(:shop, :vpndetection_api_key),
# A visitor waits on each lookup, so fail fast and don't retry.
timeout_ms: 2500,
retries: 0
})
:persistent_term.put(__MODULE__, client)
{:ok, client}
end
end# lib/shop/application.ex, in start/2
children = [
# ...
{Phoenix.PubSub, name: Shop.PubSub},
# Start a worker by calling: Shop.Worker.start_link(arg)
# {Shop.Worker, arg},
# One client for the whole app, started before the endpoint serves.
Shop.VPNDetection,
# Start to serve requests, typically the last entry
ShopWeb.Endpoint
]Requests read the client from :persistent_term, which is built for terms read far more often than they're written, so none of them waits on Shop.VPNDetection. When we killed the cache process, the link took Shop.VPNDetection down with it, and the supervisor started a new one with a new client.
Anywhere shorter-lived costs you the cache. Built in the plug's call/2, a client came and went with each request: 108 requests from the same visitor cost 108 lookups, counted at a proxy that logged each one, against 1 with Shop.VPNDetection. Built in the plug's init/1, it worked in development, where Phoenix initializes plugs on every request, at the same price: 9 requests, 9 lookups. In production, Phoenix initializes a router's plugs when it compiles the router, and MIX_ENV=prod mix compile stopped at lib/shop_web/router.ex with cannot escape #Reference<...>.
With VPNDETECTION_API_KEY unset, System.get_env/1 answers nil, and the client sends its lookups with no key.
Look up the routes that need it
The plug looks the visitor up and leaves the answer in conn.assigns:
# lib/shop_web/vpn_check.ex
defmodule ShopWeb.VPNCheck do
@moduledoc "Looks the visitor up and assigns the answer as :vpndetection."
@behaviour Plug
import Plug.Conn
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
ip = conn.remote_ip |> :inet.ntoa() |> to_string()
conn
|> assign(:client_ip, ip)
|> assign(:vpndetection, Shop.VPNDetection.lookup(ip))
end
endlookup/2 takes the tuple conn.remote_ip holds as well as a string. :inet.ntoa/1 writes it out so that /auth/check can print the address it looked up. A pipeline runs the plug for the routes under /auth, and /auth/check answers what it found:
# lib/shop_web/router.ex, in ShopWeb.Router
pipeline :vpn_check do
plug ShopWeb.VPNCheck
end
# Only the routes under /auth pay for a lookup.
scope "/auth", ShopWeb do
pipe_through [:api, :vpn_check]
get "/check", CheckController, :show
post "/signup", AuthController, :signup
post "/checkout", AuthController, :checkout
end# lib/shop_web/controllers/check_controller.ex
defmodule ShopWeb.CheckController do
use ShopWeb, :controller
# What the plug looked up, and what came back.
def show(conn, _params) do
{result, error} =
case conn.assigns.vpndetection do
{:ok, result} -> {result.raw, nil}
{:error, error} -> {nil, Map.take(error, [:kind, :message])}
end
json(conn, %{looked_up: conn.assigns.client_ip, result: result, error: error})
end
endNothing is blocked yet. result.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. looked_up read ::ffff:141.98.252.67: the generated config/runtime.exs binds production to {0, 0, 0, 0, 0, 0, 0, 0}, every interface over IPv6, so Phoenix sees an IPv4 visitor as an IPv4-mapped IPv6 address, and the client judges it as the IPv4 address it carries. 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 client answers it in the process.
Which address the plug looks up
conn.remote_ip is the address that opened the connection. Plug's docs say it "is meant to be overwritten by plugs that understand e.g. the X-Forwarded-For header", and Plug ships one. 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, terminating TLS, with the usual proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for and X-Forwarded-Proto $scheme. The Mullvad visitor sent X-Forwarded-For: 1.1.1.1 through nginx and straight to the app, and X-Real-IP: 1.1.1.1 through nginx. Straight to the app, it also sent X-Forwarded-Proto: https, or the generated production config redirects it to https. The address each choice looked up:
| Address | Through nginx | Through nginx, X-Real-IP | Straight to the app |
|---|---|---|---|
conn.remote_ip | nginx | nginx | the visitor |
Plug.RewriteOn | 1.1.1.1 | the visitor | 1.1.1.1 |
RemoteIp | the visitor | 1.1.1.1 | 1.1.1.1 |
RemoteIp, one header | the visitor | the visitor | 1.1.1.1 |
RemoteIpRewriter | nginx | nginx | the visitor |
client_ip/1, below | the visitor | the visitor | the visitor |
Through nginx with no forged header, every row but conn.remote_ip and RemoteIpRewriter found the visitor. Behind nginx, conn.remote_ip is nginx's address, ::ffff:172.28.5.12, a private address the client answers itself, so nobody is ever flagged.
plug Plug.RewriteOn, [:x_forwarded_for], in the endpoint, reads the left-most entry, and nginx appends the address it saw, so the forgery stays first. Its docs say to use it only if "your proxy strips the given x-forwarded-* headers from all incoming requests", which the usual nginx config doesn't. 1.1.1.1 answers is_hosting and is_cdn, but not is_vpn, so a VPN check waves the visitor through.
remote_ip 1.2.0 walks the entries from the right, past private addresses and any proxies you list. By default it reads Forwarded, X-Forwarded-For, X-Client-Ip and X-Real-Ip as one list, in the order they arrive, and nginx passes a visitor's own X-Real-IP along after the X-Forwarded-For it writes, so that forgery came out right-most. Its docs warn about exactly that, and plug RemoteIp, headers: ~w[x-forwarded-for] closed it. It never checks who connected, though, as its README says: "If your app is not behind at least one proxy, you should not use this plug."
remote_ip_rewriter 0.1.0, released in 2018, does check: it reads the header only on a connection from a private address or a proxy you list. It compares private IPv4 ranges with IPv4 tuples, though, and on the generated :: binding nginx arrives IPv4-mapped, so it never recognized nginx. With the app bound to {0, 0, 0, 0} instead, it found the visitor every time.
So the plug checks the connection itself, and reads the one entry nginx appended, with @proxy your proxy's address as the app sees it:
# lib/shop_web/vpn_check.ex
defmodule ShopWeb.VPNCheck do
@moduledoc "Looks the visitor up and assigns the answer as :vpndetection."
@behaviour Plug
import Plug.Conn
# nginx, on the network it shares with the app.
@proxy {172, 28, 5, 12}
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
ip = client_ip(conn)
conn
|> assign(:client_ip, ip)
|> assign(:vpndetection, Shop.VPNDetection.lookup(ip))
end
# The right-most X-Forwarded-For entry on a connection from nginx,
# and the connection's own address on any other.
defp client_ip(conn) do
peer = unmapped(conn.remote_ip)
case get_req_header(conn, "x-forwarded-for") do
[forwarded] when peer == @proxy ->
forwarded |> String.split(",") |> List.last() |> String.trim()
_ ->
peer |> :inet.ntoa() |> to_string()
end
end
# Bound to ::, as Phoenix's prod config is, the app sees an IPv4
# visitor as an IPv4-mapped IPv6 address.
defp unmapped({0, 0, 0, 0, 0, 0xFFFF, _, _} = ip),
do: :inet.ipv4_mapped_ipv6_address(ip)
defp unmapped(ip), do: ip
endWithout unmapped/1, client_ip/1 never recognized nginx on the :: binding either.
Decide per route
Signup and checkout sit under /auth, so the plug has already looked the visitor up and each action only has to decide:
# lib/shop_web/controllers/auth_controller.ex
defmodule ShopWeb.AuthController do
use ShopWeb, :controller
plug :refuse_masked when action in [:checkout]
# Signup: nobody is refused, but a masked visitor confirms their email first.
def signup(conn, _params), do: json(conn, %{confirm_email: masked?(conn)})
def checkout(conn, _params), do: json(conn, %{ordered: true})
# A VPN, residential proxy or Tor visitor, or one we know nothing about.
defp masked?(conn) do
case conn.assigns.vpndetection do
{:ok, r} -> r.is_vpn or flag(r, :is_resproxy) or flag(r, :is_tor)
{:error, _} -> true
end
end
# Checkout: a VPN, Tor, or a residential proxy exit seen 5 times or more.
defp refuse_masked(conn, _opts) do
with {:ok, r} <- conn.assigns.vpndetection,
true <- r.is_vpn or flag(r, :is_tor) or busy_proxy?(r) do
conn
|> put_status(:forbidden)
|> json(%{error: "Please turn off your VPN or proxy to check out."})
|> halt()
else
_ -> conn
end
end
defp busy_proxy?(r) do
flag(r, :is_resproxy) and (get_in(r, [:resproxy, :hits]) || 0) >= 5
end
# A flag your plan doesn't include is absent: read it as false.
defp flag(r, name), do: Map.get(r, name, false)
endrefuse_masked/2 is a function plug: it answers 403 and halts, so checkout/2 never runs. Every flag but is_vpn is absent from a result when your plan doesn't include it, so flag/2 reads a missing one as false. masked?/1 also answers true when the lookup failed, since then you know nothing about the visitor.
We sent both routes the Mullvad server and a home address through nginx, then two residential proxy exits from our data as nginx hands them over, one with hits at 25 and one at 1:
| Visitor | POST /auth/signup | POST /auth/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."}.
When a lookup fails
The plug fails open: when our API is unreachable, your quota is spent or the key is wrong, conn.assigns.vpndetection is {:error, error} and the request goes on. With the app's api.vpndetection.io pointed at an address that refuses connections, signup answered in about 4 ms, its error of kind :network. Pointed at one that never answers, every request under /auth took 2.5 seconds, the timeout_ms above. With an invalid key, the error's message read invalid API key, its kind :unauthorized. Each time, signup asked the Mullvad visitor for their email and checkout took their order. Fail open, and ask more of a visitor whose lookup failed, as signup does.
The client's own defaults suit a script, not a request: 30 seconds a try and 2 retries. Without timeout_ms and retries, a signup against the API that never answered took 90.7 seconds, and through nginx, the visitor got a 504 after 60, nginx's default proxy_read_timeout.
One client keeps answers for an hour, up to 10,000 addresses, so a returning visitor costs nothing within the hour: the 108 requests above cost one lookup. 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
Without a key, the Mullvad server's result reads:
{
"ip": "141.98.252.67",
"is_vpn": true
}Every other field is missing, which means "not in your plan", not "checked, and no". In Elixir, Map.get(result, :is_hosting) is nil for a flag your plan doesn't include, and Map.has_key?(result, :is_hosting) tells the two apart. 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 proxy exit with 25 hits signed up without confirming an email and placed its order.
For an Erlang app on Cowboy without Phoenix, vpndetection_cowboy is our middleware.


