# How to detect VPN users in Flask

Add VPN detection to a Flask app with vpndetection-flask: give ProxyFix your real proxy count, then check signup and checkout in their own views.

This tutorial adds VPN detection to a Flask app with [`vpndetection-flask`](https://docs.vpndetection.io/integrations/flask), our official extension. By the end, signup asks visitors on a VPN, a residential proxy or Tor to confirm their email, and checkout turns them away, each decided in its own view. Before either works, one number has to be right: the hop count you give werkzeug's `ProxyFix`, which, set one too high, lets a visitor on a VPN choose the address you look up. We ran every snippet against the live API on October 9, 2026, with Python 3.12, Flask 3.1.3, Werkzeug 3.1.9, gunicorn 26.2.0 and `vpndetection-flask` 2.1.2, and every output below is from those runs.

## Look up every request

```bash
pip install flask vpndetection-flask
```

The extension needs Python 3.11 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.

```python
# app.py
import os
from dataclasses import asdict

from flask import Flask, jsonify
from vpndetection_flask import VPNDetection, lookup

app = Flask(__name__)
VPNDetection(app, api_key=os.environ.get("VPNDETECTION_API_KEY"))


@app.get("/")
def index():
    found = lookup()
    return jsonify(
        ip=found.ip,
        result=asdict(found.result) if found.result else None,
        error=repr(found.error) if found.error else None,
        blocked=found.blocked,
    )
```

The extension looks the visitor up before each view runs, and `lookup()` reads the answer inside one. Nothing is blocked. For an app factory, create `VPNDetection(api_key=...)` at module level and call its `init_app(app)` in the factory.

We sent a request to this app from `141.98.252.67`, a Mullvad server in London from [Mullvad's own server list](https://mullvad.net/en/servers). Part of what came back:

```json
{
  "is_vpn": true,
  "is_hosting": true,
  "is_tor": false,
  "is_resproxy": false,
  "vpn": {
    "confidence": "high",
    "last_seen": "Fri, 09 Oct 2026 00:00:00 GMT",
    "method": "scan",
    "provider": "mullvad"
  }
}
```

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 `null` while the flag is `false`. `last_seen` is a `date` in Python, and Flask's `jsonify` writes dates in that HTTP format: call `.isoformat()` on one before you hand it on. 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 extension answers it in the process, with `is_bogon` set.

## Count your proxies

The extension looks up `request.remote_addr`, the address that opened the connection. Behind nginx or a load balancer, that's the proxy, and Flask's answer is werkzeug's `ProxyFix`, which rewrites `remote_addr` from `X-Forwarded-For`. Its [deployment docs](https://flask.palletsprojects.com/en/stable/deploying/proxy_fix/) put the catch plainly:

> Remember, only apply this middleware if you are behind a proxy, and set the correct number of proxies that set each header. It can be a security issue if you get this configuration wrong.

`x_for` is that number. We put nginx 1.29.1 in front of the app in a container of its own, at `172.28.5.12`, with the usual `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for`, and ran the app under gunicorn. 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 extension looked up:

| `ProxyFix` | Through nginx | Straight to the app |
|---|---|---|
| none | `172.28.5.12` | `141.98.252.67` |
| `x_for=1` | `141.98.252.67` | `1.1.1.1` |
| `x_for=2` | `1.1.1.1` | `141.98.252.67` |

Without `ProxyFix`, every visitor through nginx is nginx, a private address the extension answers locally, so nobody is ever flagged. The one sign is a line in gunicorn's output:

```
resolved the client address as 172.28.5.12, 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.
```

`ProxyFix` counts entries from the right of `X-Forwarded-For`, and nginx appends the address it saw, so with one nginx, `x_for=1` lands on the visitor:

```python
# behind_nginx.py
from werkzeug.middleware.proxy_fix import ProxyFix

from app import app

app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1)
```

With `x_for=2` behind the same nginx, `ProxyFix` steps past it to the entry the visitor wrote. `1.1.1.1` answers `is_hosting` but not `is_vpn`, so a VPN check waves them through. Straight to the app, `x_for=2` happened to read right only because the header had a single entry, and `ProxyFix` leaves `remote_addr` alone when there are fewer entries than `x_for`.

The middle column of `x_for=1` is the other half of the docs' warning: a visitor who reaches the app without nginx writes the entry `ProxyFix` trusts. So bind gunicorn where only nginx can reach it, `127.0.0.1` or a private network the two share, and set `x_for` to the number of proxies a request really passes.

Behind a CDN, `remote_addr` is the CDN. From a Cloudflare address, the first app looked up that address and found `is_cdn: true`. Read the header the CDN writes instead:

```python
# cloudflare.py
import os

from flask import Flask, jsonify
from vpndetection_flask import VPNDetection, header_ip_selector, lookup

app = Flask(__name__)
VPNDetection(
    app,
    api_key=os.environ.get("VPNDETECTION_API_KEY"),
    ip_selector=header_ip_selector("CF-Connecting-IP"),  # Akamai: True-Client-IP
)


@app.get("/")
def index():
    return jsonify(ip=lookup().ip)
```

From Cloudflare's address, `CF-Connecting-IP: 141.98.252.67` came back as the VPN, and with the header missing, the selector fell back to the connecting address. Straight to the app, `CF-Connecting-IP: 1.1.1.1` was looked up as `1.1.1.1`, so let only [Cloudflare's ranges](https://www.cloudflare.com/ips/) reach your origin.

## Decide per view

Most views don't care who's on a VPN; signup and checkout do. The extension has already looked the visitor up, so each of those views only has to decide:

```python
# views.py
import os

from flask import Flask, jsonify
from vpndetection_flask import VPNDetection, block_if, lookup

app = Flask(__name__)
VPNDetection(app, api_key=os.environ.get("VPNDETECTION_API_KEY"))


def is_masked() -> bool:
    """A VPN, residential proxy or Tor visitor, or one we know nothing about."""
    found = lookup()
    if found is None or found.result is None:
        return True
    flags = ("is_vpn", "is_resproxy", "is_tor")
    return any(found.result.flagged(flag) for flag in flags)


def turn_away(found):
    response = jsonify(error="Please turn off your VPN or proxy to check out.")
    response.status_code = 403
    return response


# Signup: nobody is refused, but a masked visitor confirms their email first.
@app.post("/signup")
def signup():
    return jsonify(confirm_email=is_masked())


# Checkout: a VPN, Tor, or a residential proxy exit seen 5 times or more.
@app.post("/checkout")
@block_if(
    [
        {"is_vpn": True},
        {"is_tor": True},
        {"is_resproxy": True, "resproxy": {"hits": {"gte": 5}}},
    ],
    on_blocked=turn_away,
)
def checkout():
    return jsonify(ordered=True)
```

`flagged()` is `True` only for a flag that's there and true, so one your plan doesn't include reads as `False`. `is_masked` also answers `True` when the lookup failed, since then you know nothing about the visitor. `block_if` goes below the route, and it judges the answer the extension already attached, so the decorated view costs no second lookup. Without `on_blocked`, it answers a 403 with `{"error": "access denied"}`.

We sent both views the Mullvad server, the home address, and two residential proxy exits from our data, one with `hits` at 27 and one at 2:

| Visitor | `POST /signup` | `POST /checkout` |
|---|---|---|
| Mullvad server | `{"confirm_email":true}` | 403 |
| Home broadband | `{"confirm_email":false}` | 200 |
| Residential proxy exit, `hits` 27 | `{"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."}`.

Each dict 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 view, pass the same list as `block_condition` to `VPNDetection` instead.

## When a lookup fails

The extension fails open: when our API is unreachable, your quota is spent or the key is wrong, the view runs with `result` as `None` and the reason in `error`. With an invalid key, the first app's `error` was:

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

With the same key, signup asked the Mullvad visitor for their email, and checkout took their order. `fail_closed=True` on `block_if` refuses instead, which makes any outage of ours an outage of your checkout. Fail open, and ask more of a visitor whose lookup failed, as signup does.

Each gunicorn worker caches answers for an hour, and private and reserved addresses never leave the process. A miss holds the visitor for one request of at most 2.5 seconds. Static files cost a lookup each, like any other path, unless you pass `VPNDetection` a `skip` function that leaves them out. A skipped request has no answer, so `lookup()` is `None`, which `is_masked` treats as a failed lookup and `block_if` lets through.

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 `null`s, `None` in Python, 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 without a key, checkout still refused the Mullvad server on `is_vpn`, and took the order from the proxy exit with 27 hits. `block_if` said so once in the log:

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

The same setup in Django, where the trap is a different one, is in [How to detect VPN users in Django](https://vpndetection.io/blog/detect-vpn-users-django).

