# How to detect VPN users in Django

Detect VPN, proxy and Tor visitors in Django with the official middleware, tested live, and close the X-Forwarded-For bypass the usual snippet leaves open.

This tutorial adds VPN detection to a Django app with [`vpndetection-django`](https://docs.vpndetection.io/integrations/django), our official middleware. By the end, each view can tell a VPN, residential proxy or Tor visitor from everyone else: signup asks them to confirm their email, and checkout turns them away. On the way, it closes a hole in the usual client-address snippet, which lets anyone on a VPN pass as a home user. We ran every snippet against the live API on October 2, 2026, with Python 3.12, Django 6.1.1, `vpndetection-django` 2.0.8 and django-ratelimit 4.1.0, and every output below is from those runs.

## Look up every request

```bash
pip install django vpndetection-django
```

The middleware needs Python 3.11 or newer and Django 4.2 or newer. 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.

Add the middleware after Django's own, and give it the key:

```python
# settings.py
import os
from pathlib import Path

from vpndetection_django import xff_ip_selector

# ...

MIDDLEWARE = [
    # ...
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    'vpndetection_django.VPNDetectionMiddleware',
]

VPNDETECTION = {
    'api_key': os.environ.get('VPNDETECTION_API_KEY'),
    'ip_selector': xff_ip_selector(1),  # nginx in front: see the next section
}
```

Every request now carries `request.vpndetection`, and nothing is blocked. This view shows what's on it:

```python
# views.py
from dataclasses import asdict

from django.http import JsonResponse


def index(request):
    found = request.vpndetection
    return JsonResponse({
        'ip': found.ip,
        'result': asdict(found.result) if found.result else None,
        'error': repr(found.error) if found.error else None,
        'blocked': found.blocked,
    })
```

Route it at `''` in `urls.py`. To try it without a VPN, send the header nginx would add for a visitor. `23.165.56.3` is a Mullvad server in New York, from [Mullvad's own server list](https://mullvad.net/en/servers):

```bash
curl -s localhost:8000/ -H 'X-Forwarded-For: 23.165.56.3' \
  | jq '.result | {is_vpn, is_hosting, is_tor, is_resproxy, vpn}'
```

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

That's a key on the Max plan. The result also has `is_relay`, `is_cdn`, `is_dcproxy` and `is_mobproxy`, and a detail object under each flag, whose fields are all `None` while the flag is `False`. 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 middleware answers it in the process, with `is_bogon` set.

## Get the client address right

The middleware looks up `REMOTE_ADDR` unless `ip_selector` says otherwise, and Django sets `REMOTE_ADDR` to whatever opened the connection. Behind nginx, that's nginx. Django doesn't read `X-Forwarded-For` for you, and the code that comes up for it is the accepted answer to Stack Overflow's [How do I get user IP address in Django?](https://stackoverflow.com/questions/4581789/how-do-i-get-user-ip-address-in-django), viewed over 320,000 times:

```python
def get_client_ip(request):
    x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
    if x_forwarded_for:
        ip = x_forwarded_for.split(',')[0]
    else:
        ip = request.META.get('REMOTE_ADDR')
    return ip
```

It takes the left-most entry, and that one is the visitor's to write. nginx's usual `$proxy_add_x_forwarded_for` appends the address it sees to whatever header the visitor sent, and so do AWS's Application Load Balancer and Cloudflare. We sent the app the request nginx would forward for a visitor on the Mullvad server who set their own `X-Forwarded-For` to a home broadband address, so the header arrived as that address followed by `23.165.56.3`:

| `ip_selector` | Address looked up | `is_vpn` |
|---|---|---|
| not set | `127.0.0.1` | `False` |
| `get_client_ip` | the home address | `False` |
| `xff_ip_selector()` | the home address | `False` |
| `xff_ip_selector(1)` | `23.165.56.3` | `True` |

Unset, every visitor is nginx, which the middleware answers locally as private, so nobody is ever flagged. The one sign is a warning on the `vpndetection` logger:

```
resolved the client address as 127.0.0.1, which is not a public address. If this application runs behind a proxy or load balancer, configure its trusted-proxy setting or pass an ip_selector that reads your edge's header.
```

`get_client_ip` and `xff_ip_selector()` both read the left-most entry, so a VPN user passes as whoever they name with one `curl -H`. `xff_ip_selector(1)` counts from the right and takes the entry nginx added: the address that actually connected to it. Behind a load balancer and then nginx, it's `xff_ip_selector(2)`.

A hop count believes whatever connects, though. We started the same app on every interface and sent it the home address in `X-Forwarded-For` directly, skipping nginx, and `xff_ip_selector(1)` looked up the home address. So keep the app where only your proxy can reach it: gunicorn and `runserver` listen on `127.0.0.1:8000` unless told otherwise. If it has to listen on a public interface, pass your own function as `ip_selector`, and read the header only when `REMOTE_ADDR` is your proxy.

Behind a CDN, read the header it writes instead:

```python
from vpndetection_django import header_ip_selector

VPNDETECTION = {
    'api_key': os.environ.get('VPNDETECTION_API_KEY'),
    'ip_selector': header_ip_selector('CF-Connecting-IP'),  # Akamai: True-Client-IP
}
```

With the header missing, it falls back to `REMOTE_ADDR`. Anyone who reaches your origin without going through Cloudflare can send their own `CF-Connecting-IP`, so accept connections from Cloudflare's addresses only.

## Decide per view

A VPN visitor reading your docs costs you nothing. A new account from a Tor exit or a card payment through a residential proxy might, so look everyone up once and decide in each view. The rate limit below uses [django-ratelimit](https://pypi.org/project/django-ratelimit/):

```bash
pip install django-ratelimit
```

```python
# views.py
from functools import wraps

from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django_ratelimit.decorators import ratelimit
from vpndetection.middleware import matches, validate


def is_masked(request):
    """A VPN, residential proxy or Tor visitor, as far as your plan can tell."""
    result = request.vpndetection.result
    flags = ('is_vpn', 'is_resproxy', 'is_tor')
    return result is not None and any(result.flagged(flag) for flag in flags)


def client_ip(group, request):
    return request.vpndetection.ip


def browsing_rate(group, request):
    return '20/m' if is_masked(request) else '100/m'


# Browsing: a masked visitor gets a fifth of the usual rate.
@ratelimit(key=client_ip, rate=browsing_rate)
def index(request):
    return JsonResponse({'ok': True})


# Signup: nobody is refused, but a masked visitor confirms their email first.
@require_POST
def signup(request):
    confirm_email = is_masked(request) or request.vpndetection.error is not None
    return JsonResponse({'confirm_email': confirm_email})
```

`result.flagged('is_tor')` is `True` only when the flag is there and true, so a flag your plan doesn't include reads as `False`. Signup also asks for the email when the lookup failed, since then you know nothing about the visitor. The rate limit counts per `request.vpndetection.ip`, the address the middleware settled on: django-ratelimit's own `key='ip'` reads `REMOTE_ADDR`, which behind nginx puts every visitor in one bucket. Past the limit, a request gets django-ratelimit's 403.

## Block at checkout

The middleware can refuse requests itself. Give it a `block_condition`, and `on_blocked` for the response:

```python
from django.http import JsonResponse


def turn_away(request, lookup):
    return JsonResponse({'error': 'Please turn off your VPN.'}, status=403)


VPNDETECTION = {
    'api_key': os.environ.get('VPNDETECTION_API_KEY'),
    'ip_selector': xff_ip_selector(1),
    'block_condition': [{'is_vpn': True}, {'is_tor': True}],
    'on_blocked': turn_away,
}
```

The Mullvad server got that 403 and the home address got through. Without `on_blocked`, a blocked request gets `403` and `{"error": "access denied"}`. Either way, the middleware refuses on every view it sees. For one view, match the same condition in a decorator. `matches` and `validate` are the middleware's own, from the `vpndetection` package it installs:

```python
def refuse_if(condition, message):
    """Refuse a request whose answer matches condition, as block_condition would."""
    validate(condition)  # a condition that constrains nothing raises here

    def decorator(view):
        @wraps(view)
        def wrapped(request, *args, **kwargs):
            result = request.vpndetection.result
            if result is not None and matches(condition, result):
                return JsonResponse({'error': message}, status=403)
            return view(request, *args, **kwargs)

        return wrapped

    return decorator


# Checkout: a VPN, Tor, or a residential proxy exit seen 5 times or more is refused.
@require_POST
@refuse_if(
    [
        {'is_vpn': True},
        {'is_tor': True},
        {'is_resproxy': True, 'resproxy': {'hits': {'gte': 5}}},
    ],
    'Please turn off your VPN or proxy to check out.',
)
def checkout(request):
    return JsonResponse({'ordered': True})
```

```python
# urls.py
from django.urls import path

from . import views

urlpatterns = [
    path('', views.index),
    path('signup', views.signup),
    path('checkout', views.checkout),
]
```

A condition has the shape of a result, and only the members you name count. A list matches when any of its conditions does, a list value when any of its values does (`{'vpn': {'confidence': ['high', 'medium']}}`), and a dict of `gte`, `gt`, `lte` and `lt` compares numbers. Strings compare without regard to case.

We sent each view the Mullvad server, the home address, and two residential proxy exits from our data, one with `hits` at 33 and one at 2. The app called the API once per visitor:

| Visitor | Requests a minute | `POST /signup` | `POST /checkout` |
|---|---:|---|---|
| Mullvad server | 20 | `{"confirm_email": true}` | 403 |
| Home broadband | 100 | `{"confirm_email": false}` | 200 |
| Residential proxy exit, `hits` 33 | 20 | `{"confirm_email": true}` | 403 |
| Residential proxy exit, `hits` 2 | 20 | `{"confirm_email": true}` | 200 |

`validate` refuses a condition that constrains nothing. `{'is_vpn': False}` looks like it lets non-VPN visitors through, but a member set to `False` or `None` is ignored, so it would match every request. `refuse_if` raises as `views.py` is imported, and the same `block_condition` in settings keeps `runserver` from starting:

```
ValueError: vpndetection: block condition {'is_vpn': False} constrains nothing, which would block every request; a member set to False or None is ignored, so state the positive signals you act on
```

## When a lookup fails

A lookup that fails doesn't stop the request. An unreachable API, a spent quota and a wrong key all let it through, with `result` set to `None` and the reason on `request.vpndetection.error`. With an invalid key, the first view answered:

```bash
curl -s localhost:8000/ -H 'X-Forwarded-For: 23.165.56.3' | jq -r .error
```

```
VPNDetectionError(kind='unauthorized', status=401, message='invalid API key')
```

`kind` is one of `bad_request`, `unauthorized`, `forbidden`, `rate_limited`, `quota_exceeded`, `server_error` or `network`. With `'fail_closed': True` in `VPNDETECTION`, the same request gets the block response instead, on every view. That makes any outage of ours an outage of your checkout. Fail open, and ask more of a visitor whose lookup failed, as the signup view does.

Answers are cached for an hour, up to 10,000 addresses, and private and reserved addresses never leave the process. Each process has its own cache, so four gunicorn workers keep four. A miss is one request, which holds the visitor for at most 2.5 seconds and isn't retried; `timeout` and `retries` in `VPNDETECTION` change both. A path you never act on doesn't need a lookup at all:

```python
VPNDETECTION = {
    'api_key': os.environ.get('VPNDETECTION_API_KEY'),
    'ip_selector': xff_ip_selector(1),
    'skip': lambda request: request.path.startswith('/webhooks/'),
}
```

A skipped request has no `request.vpndetection`, and a view that reads it raises `AttributeError`, so read it with `getattr(request, 'vpndetection', None)` wherever a skipped path can land. 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 request from the first section answers:

```json
{
  "is_vpn": true,
  "is_hosting": null,
  "is_tor": null,
  "is_resproxy": null,
  "vpn": null
}
```

Those `None`s aren't `False`. They mean "not in your plan", where `False` means "checked, and no". `is_vpn` is on every plan, the hosting, Tor, relay and CDN flags start on Starter, the proxy flags on Scale, and `confidence`, `method` and `resproxy.hits` are on Max.

So on the free plan, checkout's Tor and proxy terms can never match. The middleware warns once when its own `block_condition` names a member your plan lacks:

```
block_condition names is_tor, which your plan does not include, so those terms can never match. An absent member means "not in your plan", not "checked, and no".
```

`'on_missing_field': 'raise'` turns that into an error on each request. `matches` doesn't warn, so `refuse_if` stays quiet about it. In your own code, `result.is_tor is False` means checked and not Tor.

## Next steps

The [Flask](https://docs.vpndetection.io/integrations/flask) and [FastAPI](https://docs.vpndetection.io/integrations/fastapi) middleware are built on the same core, with the same `block_condition`, `ip_selector` and `fail_closed` options. The Django package is on [PyPI](https://pypi.org/project/vpndetection-django/) with its source on [GitHub](https://github.com/vpndetection-io/sdk-python-django), and every field the API answers is in the [docs](https://docs.vpndetection.io).

