# 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.

This tutorial adds VPN detection to a FastAPI app with [`vpndetection-fastapi`](https://docs.vpndetection.io/integrations/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](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
# 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](https://mullvad.net/en/servers):

```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](https://fastapi.tiangolo.com/advanced/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:

| uvicorn | Through nginx | Straight to the app |
|---|---|---|
| default | `172.28.5.12` | `141.98.252.67` |
| `--forwarded-allow-ips='*'` | `1.1.1.1` | `1.1.1.1` |
| `--forwarded-allow-ips=172.28.5.12` | `141.98.252.67` | `141.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](https://pypi.org/project/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](https://www.cloudflare.com/ips/) 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:

| 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 `{"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](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:

```
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](https://vpndetection.io/blog/detect-vpn-users-django) uses the same options from settings, and the [Flask](https://docs.vpndetection.io/integrations/flask) middleware is built on the same core. The FastAPI package is on [PyPI](https://pypi.org/project/vpndetection-fastapi/) with its source on [GitHub](https://github.com/vpndetection-io/sdk-python-fastapi), and every field the API answers is in the [docs](https://docs.vpndetection.io).

