Skip to content
VPNDetection

How to detect VPN users in FastAPI

Detect VPN, proxy and Tor visitors in FastAPI with the official middleware, tested live, and the uvicorn setting that lets a VPN user pick their own address.

Published 6 min read

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

This tutorial adds VPN detection to a FastAPI app with vpndetection-fastapi, our official middleware. 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 by a dependency. On the way, it replaces the uvicorn setting FastAPI's own docs give for running behind a proxy, which lets anyone on a VPN pick the address you look up. We ran every snippet against the live API on October 9, 2026, with Python 3.12, FastAPI 0.143.0, uvicorn 0.54.0 and vpndetection-fastapi 2.1.2, and every output below is from those runs.

Look up every request

bash
pip install fastapi uvicorn vpndetection-fastapi

The middleware needs Python 3.11 or newer, and it's plain ASGI, so it works on any Starlette app. 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.

python
# main.py
import os
from dataclasses import asdict

from fastapi import FastAPI, Request
from vpndetection_fastapi import VPNDetectionMiddleware, lookup

app = FastAPI()
app.add_middleware(
    VPNDetectionMiddleware, api_key=os.environ.get("VPNDETECTION_API_KEY")
)


@app.get("/")
async def index(request: Request):
    found = lookup(request)
    return {
        "ip": found.ip,
        "result": asdict(found.result) if found.result else None,
        "error": repr(found.error) if found.error else None,
        "blocked": found.blocked,
    }
bash
uvicorn main:app

Every request now carries an answer, which lookup(request) reads, and nothing is blocked. uvicorn believes X-Forwarded-For from 127.0.0.1 and ::1 out of the box, so curl on the same machine can stand in for a visitor. 141.98.252.67 is a Mullvad server in London, from Mullvad's own server list:

bash
curl -s localhost:8000/ -H 'X-Forwarded-For: 141.98.252.67' \
  | jq '.result | {is_vpn, is_hosting, is_tor, is_resproxy, vpn}'
json
{
  "is_vpn": true,
  "is_hosting": true,
  "is_tor": false,
  "is_resproxy": false,
  "vpn": {
    "provider": "mullvad",
    "last_seen": "2026-10-08",
    "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 null 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 request.client.host, and uvicorn sets it. Behind a proxy, uvicorn swaps in an address from X-Forwarded-For, but only for a connection from an address in --forwarded-allow-ips, which defaults to 127.0.0.1,::1. That fits nginx on the same machine. In Docker, Kubernetes or behind a load balancer, the proxy connects from an address of its own, and FastAPI's Behind a Proxy page has a setting for that:

If you set it to --forwarded-allow-ips="*" it would trust all the incoming IPs.

If your server is behind a trusted proxy and only the proxy talks to it, this would make it accept whatever is the IP of that proxy.

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. A visitor on the Mullvad server sent it X-Forwarded-For: 1.1.1.1, nginx added the address it saw, and the app got 1.1.1.1, 141.98.252.67. Then the visitor sent the same header straight to the app. The address the middleware looked up:

uvicornThrough nginxStraight to the app
default172.28.5.12141.98.252.67
--forwarded-allow-ips='*'1.1.1.11.1.1.1
--forwarded-allow-ips=172.28.5.12141.98.252.67141.98.252.67

By default, every visitor through nginx is nginx, a private address the middleware answers locally, so nobody is ever flagged. The one sign is a line in uvicorn'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.

With *, uvicorn takes the left-most entry of X-Forwarded-For, and that one is the visitor's to write. Only nginx talked to the app, as the docs ask, and the VPN user was still looked up as 1.1.1.1, which answers is_hosting but not is_vpn, so a VPN check waves them through.

Name the proxy instead. Given nginx's address, uvicorn reads the header from the right, skips the addresses it trusts and stops at the first one it doesn't: the one nginx saw. A subnet works as well, so a proxy that changes address with each deploy is still believed: --forwarded-allow-ips=172.28.5.0/28 gave the same answers, and so did FORWARDED_ALLOW_IPS=172.28.5.12 in the environment instead of the flag. fastapi run hands the flag to uvicorn, and FastAPI CLI 0.0.32 gave the same table. Under gunicorn with uvicorn-worker, gunicorn's own --forwarded-allow-ips is the one that counts: gunicorn 26.2.0 with uvicorn-worker 0.4.0 gave the table's three answers through nginx.

Reading the header yourself has the same hole. This ip_selector, the shape most snippets take, looked up 1.1.1.1 through nginx and straight to the app, whether or not uvicorn named nginx, since it reads the raw header, whatever uvicorn makes of it:

python
def first_forwarded(request: Request) -> str:
    forwarded = request.headers.get("x-forwarded-for")
    return forwarded.split(",")[0] if forwarded else request.client.host

Behind a CDN, request.client.host is the CDN. From a Cloudflare address, with no selector, the middleware looked up that address and found is_cdn: true. Read the header the CDN writes instead:

python
from vpndetection_fastapi import VPNDetectionMiddleware, header_ip_selector, lookup

app.add_middleware(
    VPNDetectionMiddleware,
    api_key=os.environ.get("VPNDETECTION_API_KEY"),
    ip_selector=header_ip_selector("CF-Connecting-IP"),  # Akamai: True-Client-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 route

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

python
# main.py
import os
from typing import Annotated

from fastapi import Depends, FastAPI, Request
from vpndetection_fastapi import VPNDetectionMiddleware, block_if, lookup

app = FastAPI()
app.add_middleware(
    VPNDetectionMiddleware, api_key=os.environ.get("VPNDETECTION_API_KEY")
)


async def is_masked(request: Request) -> bool:
    """A VPN, residential proxy or Tor visitor, or one we know nothing about."""
    found = lookup(request)
    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)


Masked = Annotated[bool, Depends(is_masked)]

# A VPN, Tor, or a residential proxy exit seen 5 times or more.
refuse_masked = block_if(
    [
        {"is_vpn": True},
        {"is_tor": True},
        {"is_resproxy": True, "resproxy": {"hits": {"gte": 5}}},
    ],
    detail="Please turn off your VPN or proxy to check out.",
)


# Signup: nobody is refused, but a masked visitor confirms their email first.
@app.post("/signup")
async def signup(masked: Masked):
    return {"confirm_email": masked}


@app.post("/checkout", dependencies=[Depends(refuse_masked)])
async def checkout():
    return {"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 raises a 403 HTTPException for a visitor its condition matches, with detail as the body, and it goes on a path operation, an APIRouter or the whole app. It judges the answer the middleware already attached, so no route costs a second lookup. A WebSocket route gets none at all: the middleware looks up HTTP requests only, and on one we connected to from the Mullvad server, lookup(websocket) was None.

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

VisitorPOST /signupPOST /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 {"detail":"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 ({"vpn": {"confidence": ["high", "medium"]}}), gte, gt, lte and lt compare numbers, and strings match without regard to case. A condition that constrains nothing is refused: {"is_vpn": False} reads like "let non-VPN visitors through", but a member set to False or None is ignored, so it would match every request, and block_if raises as main.py is imported:

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

The lookup happens in the middleware, before routing, and it's awaited, so a visitor waiting on our API holds up nobody else. One lookup took 85 ms, and forty visitors at once, each from an address the cache hadn't seen, were all answered in 173 ms by a single uvicorn worker. is_masked is an async def because it does no I/O: FastAPI runs a plain def dependency in its thread pool.

When a lookup fails

The middleware fails open: when our API is unreachable, your quota is spent or the key is wrong, the request goes on with result as None and the reason in error. With an invalid key, the first app answered:

bash
curl -s localhost:8000/ -H 'X-Forwarded-For: 141.98.252.67' | 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 the same key, signup asked the Mullvad visitor for their email, and checkout took their order. With fail_closed=True added to block_if, checkout refused the home address too, 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 worker process caches answers for an hour, up to 10,000 addresses, so --workers 4 keeps four caches, and private and reserved addresses never leave the process. A miss holds the visitor for one request of at most 2.5 seconds, with no retry; the middleware's timeout and retries change both. FastAPI's own /docs and /openapi.json cost a lookup each, like any other path, and skip leaves them out:

python
app.add_middleware(
    VPNDetectionMiddleware,
    api_key=os.environ.get("VPNDETECTION_API_KEY"),
    skip=lambda request: request.url.path in ("/docs", "/openapi.json"),
)

A skipped request has no answer, so lookup(request) 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:

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

block_if 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".

With on_missing_field="raise", it's a ValueError on every request block_if judges, which FastAPI answers with a 500. In your own code, result.is_tor is False means checked and not Tor.

Next steps

Our Django tutorial uses the same options from settings, and the Flask middleware is built on the same core. The FastAPI 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.