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

This tutorial adds VPN detection to a Phoenix app with [`vpndetection`](https://docs.vpndetection.io/integrations/erlang), 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](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 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](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. `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](https://plug.hexdocs.pm/Plug.Conn.html) 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](https://plug.hexdocs.pm/Plug.RewriteOn.html) 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`](https://hex.pm/packages/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](https://github.com/ajvondrak/remote_ip) says: "If your app is not behind at least one proxy, you should not use this plug."

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

| 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](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

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`](https://docs.vpndetection.io/integrations/cowboy) is our middleware.

