Skip to content
VPNDetection

How to detect VPN users in Phoenix (Elixir)

Add VPN detection to a Phoenix app with our Erlang client: start one client under the supervisor, and keep Plug.RewriteOn from taking a forged address.

Published 6 min read

On this page
  1. Start one client for the whole app
  2. Look up the routes that need it
  3. Which address the plug looks up
  4. Decide per route
  5. When a lookup fails
  6. Absent is not false

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

bash
mix phx.new shop --no-ecto
cd shop

Nothing here needs a database. Add the client to deps in mix.exs and run mix deps.get:

elixir
{: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:

elixir
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:

elixir
# 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
elixir
# 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:

elixir
# 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
end

lookup/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:

elixir
# 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
elixir
# 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
end

Nothing 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:

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. 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:

AddressThrough nginxThrough nginx, X-Real-IPStraight to the app
conn.remote_ipnginxnginxthe visitor
Plug.RewriteOn1.1.1.1the visitor1.1.1.1
RemoteIpthe visitor1.1.1.11.1.1.1
RemoteIp, one headerthe visitorthe visitor1.1.1.1
RemoteIpRewriternginxnginxthe visitor
client_ip/1, belowthe visitorthe visitorthe 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:

elixir
# 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
end

Without 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:

elixir
# 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)
end

refuse_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:

VisitorPOST /auth/signupPOST /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:

json
{
  "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.

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.