Skip to content
VPNDetection

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.

Published 6 min read

On this page
  1. Look up every request
  2. Get the client address right
  3. Decide per view
  4. Block at checkout
  5. When a lookup fails
  6. Absent is not false
  7. Next steps

This tutorial adds VPN detection to a Django app with vpndetection-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: 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:

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?, 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_selectorAddress looked upis_vpn
not set127.0.0.1False
get_client_ipthe home addressFalse
xff_ip_selector()the home addressFalse
xff_ip_selector(1)23.165.56.3True

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:

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:

VisitorRequests a minutePOST /signupPOST /checkout
Mullvad server20{"confirm_email": true}403
Home broadband100{"confirm_email": false}200
Residential proxy exit, hits 3320{"confirm_email": true}403
Residential proxy exit, hits 220{"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). With a few million distinct visitors 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:

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

Those Nones 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 and 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 with its source on GitHub, and every field the API answers is in the docs.

Written by

  • Uman Shahzad

    Founder & CEO

    IP intelligence data expert for over 8 years, former lead engineer at IPinfo, Uman has pioneered many systems and practices used by IP data companies to operate at scale, provide high-quality data and offer the best developer experiences.

Get new posts by email

Or follow along in a feed reader.

RSS feed

Try it on your own traffic.

The detection these posts describe is one API call away, with SDKs for every major language.

00 · No key

Start without signing up.

Paste it into a terminal. No account needed. 1k daily allowance per user, answering ip and is_vpn.

curl "https://api.vpndetection.io/45.83.91.1"
{
"ip": "45.83.91.1",
"is_vpn": true
}
01 · Free key

Sign up and get 50,000 requests a month.

No card and no sales call: the key is issued on signup.
Every request flags VPN IPs.
Upgrade only when you outgrow the allowance.