# How to detect VPN users in Symfony

Add VPN detection to a Symfony app with vpndetection/symfony: name the proxies you trust, then decide signup and checkout per route.

This tutorial adds VPN detection to a Symfony app with [`vpndetection/symfony`](https://docs.vpndetection.io/integrations/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

```bash
composer require vpndetection/symfony
```

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

```php
// config/bundles.php
return [
    Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
    VPNDetection\Symfony\VPNDetectionBundle::class => ['all' => true],
];
```

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.

```yaml
# 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
<?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](https://mullvad.net/en/servers). Part of what came back under `result`:

```json
{
  "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](https://symfony.com/doc/current/deployment/proxies.html) 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:

```yaml
# 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](https://www.cloudflare.com/ips/), so trust those. With no `trusted_proxies` in `framework.yaml`, Symfony reads them from `SYMFONY_TRUSTED_PROXIES`:

```bash
{ 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.local
```

The `echo`s 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:

```bash
composer require symfony/security-bundle
```

```php
<?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
<?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:

```yaml
# 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:

```json
{"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:

```yaml
# 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](https://vpndetection.io/pricing)). With a few million requests 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 request from the first section answers:

```json
{
  "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](https://vpndetection.io/blog/detect-vpn-users-php-wordpress).

