# How to detect VPN users in Perl with Mojolicious

Detect VPN users in a Mojolicious app with our Perl client: trust only your proxy's X-Forwarded-For, and screen signups without blocking the event loop.

This tutorial adds VPN detection to a Mojolicious app with [`VPNDetection`](https://docs.vpndetection.io/integrations/perl), our official Perl client. By the end, a `Mojolicious::Lite` app turns VPN visitors away from `/signup` in an `under`, and its event loop keeps serving everyone else while a lookup is out. Two things decide whether that works: which address `$c->tx->remote_address` gives you behind a proxy, and whether the lookup waits without blocking the loop. We ran every snippet against the live API on October 21, 2026, with Perl 5.44.0, Mojolicious 9.51, Plack 1.0055, Starman 0.4018, nginx 1.29.1 and `VPNDetection` 3.10.0, and every output below is from those runs.

## Screen a signup

```bash
cpanm VPNDetection
```

The client needs Perl 5.22 or newer, and Mojolicious is its only runtime dependency, plus `IO::Socket::SSL` for TLS. It's on [MetaCPAN](https://metacpan.org/dist/VPNDetection), with its source on [GitHub](https://github.com/vpndetection-io/sdk-perl). Get an API key in the [console](https://app.vpndetection.io): a new account tries every field, 100 lookups a day. Without a key, the API still answers, with no daily cap.

Save this as `app.pl`:

```perl
use Mojolicious::Lite -signatures;
use VPNDetection;

# One client for the whole app: it caches each answer for an hour.
my $vpnd = VPNDetection->new(api_key => $ENV{VPNDETECTION_API_KEY});

get '/' => {text => "Welcome\n"};

# What the API says about the address this request came from.
get '/check' => sub ($c) {
  my $ip = $c->tx->remote_address;
  return $vpnd->lookup_p($ip)->then(sub ($result) {
    $c->render(json => {ip => $ip, result => $result->raw});
  });
};

# Every route below this waits for the visitor's lookup.
under sub ($c) {
  my $ip = $c->tx->remote_address;
  $vpnd->lookup_p($ip, timeout => 2, retries => 0)->then(sub ($result) {
    return $c->continue unless $result->is_vpn;
    $c->render(status => 403, json => {error => 'Turn off your VPN to sign up.'});
  })->catch(sub ($err) {
    $c->log->warn("VPN lookup failed, letting $ip in: $err");
    $c->continue;
  });
  return undef;
};

post '/signup' => {json => {signed_up => 1}};

app->start;
```

Make the client once, when the app starts, and its cache serves every request after. `/check` shows what the API says about whoever calls it, and every route after the `under` waits for the visitor's lookup: a VPN gets a 403, and anyone else goes on to `/signup`. We started it behind nginx with `perl app.pl daemon -p 127.0.0.1`, which the next section explains. From `185.213.155.73`, a Mullvad server in Frankfurt from [Mullvad's own server list](https://mullvad.net/en/servers), part of `/check`'s `result` read:

```json
{
  "is_vpn": true,
  "is_hosting": true,
  "is_tor": false,
  "is_resproxy": false,
  "vpn": {
    "confidence": "high",
    "last_seen": "2026-10-21",
    "provider": "mullvad"
  }
}
```

That's a key on the Max plan, and `raw` is the API's answer as it came. It also carries `location` and `asn`: Germany and AS39351 here. A home broadband line on a US cable ISP came back with every flag `false`, every detail object empty, and `location` and `asn` filled in. `POST /signup` gave the Mullvad server a 403 with `{"error":"Turn off your VPN to sign up."}`, and the home line `{"signed_up":1}`. A private or reserved address never reaches the API: the client answers it in the process.

## Which address Mojolicious believes

`$c->tx->remote_address` is the address that opened the connection, unless the server runs in reverse-proxy mode. We put nginx 1.29.1 in front with the [Cookbook's config](https://docs.mojolicious.org/Mojolicious/Guides/Cookbook#Nginx), upstream `127.0.0.1:3000`, sharing the app's network namespace so it connects from `127.0.0.1` as it would on one host. That config appends the visitor to `X-Forwarded-For` with `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for`. The Mullvad server sent `X-Forwarded-For: 1.1.1.1` to `/signup` through nginx, and then straight to port 3000, where `daemon` listens on every interface by default. What the app looked up, and how signup went:

| `perl app.pl daemon` | Through nginx | Straight to port 3000 |
|---|---|---|
| no proxy flag | `127.0.0.1`, signed up | `185.213.155.73`, 403 |
| `-p` | `185.213.155.73`, 403 | `1.1.1.1`, signed up |
| `-p 127.0.0.1` | `185.213.155.73`, 403 | `185.213.155.73`, 403 |

With no proxy flag, every visitor through nginx is `127.0.0.1`, which the client answers locally as a private address, never a VPN, so the Mullvad server signed up. `-p`, like `MOJO_REVERSE_PROXY=1` or Hypnotoad's `proxy => 1`, takes the last `X-Forwarded-For` entry: the one nginx appended when the request came through nginx, and the one the visitor wrote when it didn't. `1.1.1.1` answers `is_cdn` and `is_hosting` but not `is_vpn`, so the screen waved it through. `-p 127.0.0.1` sets [`trusted_proxies`](https://docs.mojolicious.org/Mojo/Server#trusted_proxies), in Mojolicious since 8.72: the header counts only on a connection from a listed address, and Mojolicious reads it from the right past the listed proxies, so the forged entry never came up. `MOJO_TRUSTED_PROXIES=127.0.0.1` does the same, and turns reverse-proxy mode on by itself.

Three plugins on MetaCPAN answer the same question. We gave two of them a small app of their own, run with the server's default settings:

```perl
use Mojolicious::Lite -signatures;

plugin 'RemoteAddr';
plugin ForwardedFor => {levels => 1};

get '/addresses' => sub ($c) {
  $c->render(json => {
    remote_address => $c->tx->remote_address,
    remote_addr    => $c->remote_addr,
    forwarded_for  => $c->forwarded_for,
  });
};

app->start;
```

The third, TrustedProxy, rewrites `remote_address` itself, so it ran alone in that app, registered as `plugin TrustedProxy => {trusted_sources => ['127.0.0.1']};`. For the same forged request:

| What the app reads | Through nginx | Straight to port 3000 |
|---|---|---|
| `remote_addr`, RemoteAddr 0.03 | `1.1.1.1, 185.213.155.73` | `1.1.1.1` |
| `forwarded_for`, ForwardedFor 0.002 | `185.213.155.73` | `1.1.1.1` |
| The same with `levels => 2` | `1.1.1.1` | `1.1.1.1` |
| `remote_address` with TrustedProxy 0.04 | `1.1.1.1` | `185.213.155.73` |

[RemoteAddr](https://metacpan.org/dist/Mojolicious-Plugin-RemoteAddr) returns `X-Real-IP` when a request has one, and otherwise the whole `X-Forwarded-For` header, whoever sent it. A forged `X-Real-IP: 1.1.1.1` came back as `1.1.1.1` both ways, since this nginx config passes it on untouched, and behind nginx the header's two entries came back as one string. Fed to the `under`, that string failed its lookup with `not a valid IP address`, and the Mullvad server signed up. [ForwardedFor](https://metacpan.org/pod/Mojolicious::Plugin::ForwardedFor) counts `levels` from the right and never asks who connected, so it's right behind one proxy and wrong straight to the port, and at 2, as its own docs warn, it's wrong through nginx too. [TrustedProxy](https://metacpan.org/pod/Mojolicious::Plugin::TrustedProxy) reads the headers only from its trusted sources, then takes the first `X-Forwarded-For` entry, the one the visitor wrote, and a forged `Forwarded: for=1.1.1.1` through nginx came back as `1.1.1.1` as well. With `-p 127.0.0.1`, `remote_address` came back as the Mullvad server for every one of those headers, both ways, so use Mojolicious's own setting and leave the plugins out.

In production, [Hypnotoad](https://docs.mojolicious.org/Mojo/Server/Hypnotoad#trusted_proxies) reads the same setting from the app's config, and listening on loopback keeps visitors off the app's port altogether:

```perl
app->config(hypnotoad => {
  listen          => ['http://127.0.0.1:8080'],
  trusted_proxies => ['127.0.0.1'],
});
```

With those lines above `app->start`, Hypnotoad answered all 20 signups we sent from the Mullvad server through nginx with a 403, and port 8080 refused connections from outside.

## Wait for the lookup without blocking the loop

The README's first example is the blocking `lookup`. Written into the `under`, it reads:

```perl
under sub ($c) {
  my $result = $vpnd->lookup($c->tx->remote_address);
  return 1 unless $result->is_vpn;
  $c->render(status => 403, json => {error => 'Turn off your VPN to sign up.'});
  return undef;
};
```

Every `POST /signup` was then a 500 in about 6 ms, and the log said `VPNDetection::lookup cannot block inside a running Mojo::IOLoop; call lookup_p instead, which returns a Mojo::Promise`. No request reached the API: the client checks before it builds one, so the mistake costs nothing from your allowance.

That refusal protects every other visitor. Each Mojolicious server process runs one event loop, and a call that waits stops it for every connection that process holds. We measured it with a blocking `HTTP::Tiny` request to the API in the same `under`, the API unreachable and a 2-second timeout: a request for `/` sent half a second after a signup waited 1.51 s for it. With `app.pl`'s `lookup_p`, the same request for `/` answered in about 5 ms while the signup waited out its 2 seconds.

An action can return its promise, and Mojolicious renders a rejected one as an error, so the `under` looks as if it could too:

```perl
under sub ($c) {
  return $vpnd->lookup_p($c->tx->remote_address)->then(sub ($result) {
    return 1 unless $result->is_vpn;
    $c->render(status => 403, json => {error => 'Turn off your VPN to sign up.'});
    return undef;
  });
};
```

An `under` decides on its return value at once, and a promise is a true value, so the Mullvad server signed up in 3.7 ms. Its lookup landed 69 ms later, and the 403 died with `A response has already been rendered`. That's why the `under` in `app.pl` returns `undef` and leaves the decision to the promise: `$c->continue` to go on, or a rendered 403 to stop.

## When a lookup fails

The `under` fails open: when our API is unreachable, your quota is spent or the key is wrong, the visitor signs up and the log says why. With the API refusing connections, signup answered in about 5 ms and logged:

```
VPN lookup failed, letting 185.213.155.73 in: Connection refused
```

With an invalid key, the reason was `invalid API key`. To fail closed, render the 403 in the `catch` too, which turns any outage of ours into an outage of your signups.

The `under` also gives each lookup 2 seconds and no retry. The client's defaults are 30 seconds an attempt and two retries, and with the API unreachable, each attempt failed at `Mojo::UserAgent`'s 10-second connect timeout, so the lookup gave up after 30.76 s. Mojolicious closes a connection that's been idle for 30 seconds: straight to the app, the visitor got an empty reply at 30.0 s, and through nginx a 502, before the fail-open `continue` ever ran. With the bound, the same signup answered at 2.0 s.

Each server process keeps its own cache, so under Hypnotoad's four workers, those 20 signups cost 4 lookups, one per worker. Lookups on no paid plan have no monthly cap, and paid plans start at 250,000 a month ([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).

## Plack, without Mojolicious

Outside an event loop, the blocking `lookup` is the one to call. A PSGI app for Starman or any other Plack server, saved as `app.psgi`:

```perl
use strict;
use warnings;
use Plack::Builder;
use Plack::Request;
use VPNDetection;

# One client per process, with a short bound: this call blocks its worker.
my $vpnd = VPNDetection->new(
  api_key => $ENV{VPNDETECTION_API_KEY},
  timeout => 2,
  retries => 0,
);

my $signup = sub {
  my $req    = Plack::Request->new(shift);
  my $ip     = $req->address;
  my $result = eval { $vpnd->lookup($ip) };
  warn "VPN lookup failed, letting $ip in: $@" unless $result;
  my ($status, $body)
    = $result && $result->is_vpn
    ? (403, "Turn off your VPN to sign up.\n")
    : (200, "Signed up\n");
  return [$status, ['Content-Type' => 'text/plain'], [$body]];
};

builder {
  enable_if { $_[0]->{REMOTE_ADDR} eq '127.0.0.1' } 'ReverseProxy';
  mount '/signup' => $signup;
  mount '/'       => sub { [200, ['Content-Type' => 'text/plain'], ["Welcome\n"]] };
};
```

`$req->address` is the connection's address. [ReverseProxy](https://metacpan.org/dist/Plack-Middleware-ReverseProxy) replaces it with the last `X-Forwarded-For` entry, whoever sent it, so it runs only for requests from nginx, as its own docs show. Behind the same nginx, `starman --listen 127.0.0.1:5000 --workers 4 app.psgi` gave the Mullvad server a 403 and signed the home line up, a forged `X-Forwarded-For: 1.1.1.1` changed nothing, and port 5000 refused connections from outside. With `--listen :5000`, Starman listened on IPv6 as well and handed the app nginx's address as `::ffff:127.0.0.1`. The `eq` never matched, so every visitor through nginx counted as loopback, which the client answers locally, and the Mullvad server signed up.

Each lookup holds its worker until the API answers, which is why this client gets a 2-second bound too. Under `plackup`'s one-process server with the API unreachable, a request for `/` sent half a second after a signup waited 1.51 s.

## Absent is not false

Without a key, `/check` for the Mullvad server answered:

```json
{
  "ip": "185.213.155.73",
  "result": {
    "ip": "185.213.155.73",
    "is_vpn": true
  }
}
```

Every other field is missing, and missing means your plan doesn't include it, not that the answer was no. `is_vpn`, the Tor and relay flags, `location` and `asn` come back with a key or without one, and every paid plan answers every field. In Perl, `$result->is_tor` is `undef` for a flag your plan doesn't include, and `undef` and `0` are both false, so `unless ($result->is_tor)` reads a missing flag as "not Tor". `$result->has('is_tor')` says which it is: `0` without a key and `1` with one. The `under` checks only `is_vpn`, so without a key it still refused the Mullvad server.

The same setup in Rails is in [How to detect VPN users in Ruby on Rails](https://vpndetection.io/blog/detect-vpn-users-rails).

