On this page
This tutorial adds VPN detection to a Mojolicious app with VPNDetection, 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
cpanm VPNDetectionThe client needs Perl 5.22 or newer, and Mojolicious is its only runtime dependency, plus IO::Socket::SSL for TLS. It's on MetaCPAN, with its source on GitHub. Get an API key in the console: 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:
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, part of /check's result read:
{
"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, 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, 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:
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 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 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 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 reads the same setting from the app's config, and listening on loopback keeps visitors off the app's port altogether:
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:
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:
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). With a few million distinct visitors a day, download the VPN IP database and look addresses up locally instead of calling the 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:
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 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:
{
"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.


