On this page
This tutorial adds VPN detection to a Flask app with vpndetection-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
pip install flask vpndetection-flaskThe extension needs Python 3.11 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.
# 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. Part of what came back:
{
"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 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:
# 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:
# 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 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:
# 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). 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:
{
"is_vpn": true,
"is_hosting": null,
"is_tor": null,
"is_resproxy": null,
"vpn": null
}Those nulls, 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.


