On this page
This tutorial adds VPN detection to a Symfony app with vpndetection/symfony, our official bundle. By the end, signup asks visitors on a VPN, a residential proxy or Tor to confirm their email, and a security voter turns them away from checkout. Before either works, framework.trusted_proxies has to name the proxies in front of your app: the setting Symfony's docs give for a load balancer whose address changes lets a visitor who reaches the app directly choose the address you look up. We ran every snippet against the live API on October 14, 2026, with PHP 8.5.11, Symfony 8.1.8 and vpndetection/symfony 2.3.0, and every output below is from those runs.
Look up every request
composer require vpndetection/symfonyThe bundle needs PHP 8.2 or newer and Symfony 6.4, 7 or 8. Flex doesn't register it, so add it to config/bundles.php yourself:
// config/bundles.php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
VPNDetection\Symfony\VPNDetectionBundle::class => ['all' => true],
];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.
# config/packages/vpndetection.yaml
vpndetection:
api_key: '%env(VPNDETECTION_API_KEY)%'Define VPNDETECTION_API_KEY= in .env, empty, and put the key itself in .env.local or the server's environment. Defined nowhere, it turns every request into a 500, Environment variable not found: "VPNDETECTION_API_KEY".
<?php
// src/Controller/VisitorController.php
namespace App\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use VPNDetection\Symfony\VPNDetectionListener;
final class VisitorController
{
#[Route('/')]
public function index(Request $request): JsonResponse
{
$lookup = VPNDetectionListener::lookup($request);
return new JsonResponse([
'ip' => $lookup?->ip,
'result' => $lookup?->result?->raw,
'error' => $lookup?->error?->getMessage(),
]);
}
}The bundle looks the visitor up just before Symfony's router runs, and VPNDetectionListener::lookup() reads the answer in a controller. Nothing is blocked.
We sent a request to this app from 193.32.248.66, a Mullvad server in Berlin from Mullvad's own server list. Part of what came back under result:
{
"is_vpn": true,
"is_hosting": true,
"is_tor": false,
"is_resproxy": false,
"vpn": {
"provider": "mullvad",
"last_seen": "2026-10-13",
"confidence": "high",
"method": "scan"
}
}That's a key on the Max plan. The answer also has is_relay, is_cdn, is_dcproxy and is_mobproxy, and a detail object under each flag. raw is the answer as the API sent it; in code, read the typed properties, $result->isVpn and $result->vpn->provider. A home broadband address on a US cable ISP came back with every flag false. A private or reserved address never reaches the API: the bundle answers it in the process, with isBogon set.
Name the proxies you trust
The bundle looks up $request->getClientIp(). With nginx handing requests to PHP-FPM over FastCGI, that's already the visitor: we sent the Mullvad request that way too. Behind a proxy that forwards HTTP, such as a load balancer, a CDN or nginx in front of an app container, it's the proxy, until framework.trusted_proxies names it. Then Symfony reads X-Forwarded-For from the right, skips the proxies it trusts, and stops at the first address it doesn't.
For a proxy whose address changes, such as AWS's load balancers, Symfony's docs give trusted_proxies: '127.0.0.1,REMOTE_ADDR', and warn you to let nothing but the load balancer reach your servers. REMOTE_ADDR stands for whichever address connected, so every caller is a trusted proxy.
We served the app with PHP's built-in server and put nginx 1.29.1 in front of it, in a container of its own, at 172.28.6.10, with the usual proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for. A visitor on the Mullvad server sent X-Forwarded-For: 1.1.1.1, through nginx and then straight to the app. The address the bundle looked up:
trusted_proxies | Through nginx | Straight to the app |
|---|---|---|
| not set | 172.28.6.10 | 193.32.248.66 |
'127.0.0.1,REMOTE_ADDR' | 193.32.248.66 | 1.1.1.1 |
'172.28.6.10' | 193.32.248.66 | 193.32.248.66 |
Not set, every visitor through nginx is nginx, a private address the bundle answers locally, so nobody is ever flagged. The one sign is a line in PHP's error log, on every request:
[vpndetection] resolved the client address as 172.28.6.10, which is not a public address. If this application runs behind a proxy or load balancer, configure its trusted-proxy setting or pass an ipSelector that reads your edge's header.
With REMOTE_ADDR, a visitor who reaches the app without nginx writes the address you look up. 1.1.1.1 answers is_hosting but not is_vpn, so a VPN check waves them through. Name the proxy's own address, or its subnet:
# config/packages/framework.yaml
framework:
# ...
trusted_proxies: '172.28.6.10'Symfony then trusts X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Port from it. The docs' other example, trusted_headers: ['forwarded'], is for a proxy that writes the standard Forwarded header. nginx doesn't, and passes the visitor's own on, so with that line, a visitor who sent Forwarded: for=1.1.1.1 through nginx was looked up as 1.1.1.1. Trusting both headers turned the same request into a 400, since Symfony refuses a request whose trusted headers disagree. Trust the header your proxy writes, and no other.
Behind Cloudflare, getClientIp() is a Cloudflare address, which answered is_cdn: true. Cloudflare adds the visitor to X-Forwarded-For and publishes its ranges, so trust those. With no trusted_proxies in framework.yaml, Symfony reads them from SYMFONY_TRUSTED_PROXIES:
{ curl -s https://www.cloudflare.com/ips-v4; echo
curl -s https://www.cloudflare.com/ips-v6; echo; } | paste -sd, - \
| sed 's/^/SYMFONY_TRUSTED_PROXIES=/' >> .env.localThe echos are there because neither list ends in a newline. On October 14 that made 22 ranges; fetch them again when you deploy. The bundle also takes a header name, client_ip_header: CF-Connecting-IP, and reads it from whoever sends it. From a Cloudflare address, with the headers Cloudflare adds for the Mullvad visitor, both settings looked up 193.32.248.66. Straight to the app, with CF-Connecting-IP: 1.1.1.1 and X-Forwarded-For: 1.1.1.1, client_ip_header looked up 1.1.1.1, and Cloudflare's ranges in SYMFONY_TRUSTED_PROXIES looked up 193.32.248.66.
Decide per route
Most routes don't care who's on a VPN; signup and checkout do. The bundle has already looked the visitor up, so each of those routes only has to decide. Signup reads the answer in its controller. Checkout asks a voter, which keeps the rule in one place for every controller and template that asks is_granted('CHECKOUT'). Voters come with the security bundle:
composer require symfony/security-bundle<?php
// src/Security/CheckoutVoter.php
namespace App\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use VPNDetection\Middleware\Condition;
use VPNDetection\Symfony\VPNDetectionListener;
/** Refuses checkout on a VPN, on Tor, or from a proxy exit seen 5 times or more. */
final class CheckoutVoter extends Voter
{
private const REFUSE = [
['isVpn' => true],
['isTor' => true],
['isResproxy' => true, 'resproxy' => ['hits' => ['gte' => 5]]],
];
public function __construct(private readonly RequestStack $requests)
{
}
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === 'CHECKOUT';
}
protected function voteOnAttribute(
string $attribute,
mixed $subject,
TokenInterface $token,
?Vote $vote = null,
): bool {
$request = $this->requests->getMainRequest();
$result = $request ? VPNDetectionListener::lookup($request)?->result : null;
return $result === null || !Condition::matches(self::REFUSE, $result);
}
}<?php
// src/Controller/ShopController.php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use VPNDetection\Symfony\VPNDetectionListener;
final class ShopController extends AbstractController
{
// Nobody is refused, but a masked visitor confirms their email first.
#[Route('/signup', methods: ['POST'])]
public function signup(Request $request): JsonResponse
{
$result = VPNDetectionListener::lookup($request)?->result;
$masked = $result === null
|| $result->isVpn
|| $result->isResproxy === true
|| $result->isTor === true;
return $this->json(['confirm_email' => $masked]);
}
#[Route('/checkout', methods: ['POST'])]
public function checkout(): JsonResponse
{
if (!$this->isGranted('CHECKOUT')) {
$error = 'Please turn off your VPN or proxy to check out.';
return $this->json(['error' => $error], 403);
}
return $this->json(['ordered' => true]);
}
}$result is null when the lookup failed, and then you know nothing about the visitor: signup asks for their email, and the voter lets the order through. isResproxy and isTor are compared with === true because either can be null, as "Absent is not false" below explains.
We sent both routes the Mullvad server, the home address, and two residential proxy exits from our data, one with hits at 26 and one at 2, all through nginx:
| Visitor | POST /signup | POST /checkout |
|---|---|---|
| Mullvad server | {"confirm_email":true} | 403 |
| Home broadband | {"confirm_email":false} | 200 |
Residential proxy exit, hits 26 | {"confirm_email":true} | 403 |
Residential proxy exit, hits 2 | {"confirm_email":true} | 200 |
Each 403 came with {"error":"Please turn off your VPN or proxy to check out."}.
Condition::matches() is the matcher behind the bundle's own block_condition. Each array in the list is one way to match, and it names only the fields it cares about. A list as a value accepts any of its values, gte, gt, lte and lt compare numbers, and strings match without regard to case.
To refuse on every route, give the bundle the condition instead:
# config/packages/vpndetection.yaml
vpndetection:
api_key: '%env(VPNDETECTION_API_KEY)%'
block_condition:
- { isVpn: true }
- { isTor: true }The Mullvad server then got a 403 with {"error":"access denied"} on every route, one that doesn't exist included, and the home address got through. The listener runs before the router, so a refused request costs no route match, and a forward() or ESI fragment isn't looked up again.
When a lookup fails
The bundle fails open: when our API is unreachable, your quota is spent or the key is wrong, the controller runs with result as null and the reason in error. With an invalid key, the first controller answered:
{"ip":"193.32.248.66","result":null,"error":"invalid API key"}With the same key, signup asked the Mullvad visitor for their email, and checkout took their order. fail_closed: true refuses every request whose lookup failed, on every route, which makes any outage of ours an outage of your app. Fail open, and ask more of a visitor whose lookup failed, as signup does.
The bundle keeps each answer for an hour in your app's cache.app pool, so three requests in a row from the Mullvad server made one call to the API, through nginx to PHP-FPM 8.4.26 and under PHP's built-in server alike. In a new app that pool is on the filesystem, so each server keeps its own. cache_pool in vpndetection.yaml names another pool from framework.cache.pools, and one we added there made one call too. cache_pool: false keeps answers in the client alone, which Symfony builds again for every request, and the three made three calls. So count lookups by the visitors who reach Symfony each hour; files nginx serves from public/ never do. A miss holds the visitor for one request of at most 2.5 seconds.
A request that never needs an answer, such as a health check, can skip the lookup. From 2.2.0 the bundle takes skip_paths, regular expressions matched against the path the way access_control's are:
# config/packages/vpndetection.yaml
vpndetection:
api_key: '%env(VPNDETECTION_API_KEY)%'
skip_paths: ['^/healthz', '^/assets/']Three requests to /healthz made no call to the API, and VPNDetectionListener::lookup() returned null for each. ^/healthz skipped /healthzz too, so end a pattern with $ when a longer path shouldn't match it.
A free key covers 50,000 lookups a month, and paid plans start at 250,000 (pricing). With a few million requests 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 request from the first section answers:
{
"ip": "193.32.248.66",
"is_vpn": true
}The other fields aren't in the answer, so $result->isHosting, isTor and isResproxy are null, which means "not in your plan", where false means "checked, and no". 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 isVpn, and the proxy exit with 26 hits got through both routes: signup didn't ask for its email, and checkout took its order. With isTor in its block_condition, the bundle logged on every request:
[vpndetection] blockCondition names isTor, which your plan does not include, so those terms can never match. An absent member means "not in your plan", not "checked, and no".
Plain PHP, WordPress and WooCommerce are in How to detect VPN users in PHP and WordPress.


