Detect VPN and proxy IPs in Python: Flask, Django and FastAPI (free API)

Detect VPN, proxy and Tor visitors in Flask, Django and FastAPI with one standard-library module and a free API call, tested, with a 5-minute cache.

To detect VPN and proxy users in Python, look up the visitor's IP address on the server with an IP API and act on its risk band. The module below uses only the standard library and FFraud's free endpoint, which needs no key. It plugs into Flask and Django as a decorator and into FastAPI as a dependency. In our test a cached answer took 0.4 microseconds, and a fresh one a median of 188 ms.

#How do you check an address in Python?

ipcheck.py
"""Detect VPN, proxy, Tor and data center visitors with FFraud's free IP API. Standard library only, no key."""
import json
import time
import urllib.parse
import urllib.request

API = "https://api.ffraud.com/public/ip/"
_cache: dict[str, tuple[float, dict]] = {}


def ip_check(ip: str | None, timeout: float = 2.0) -> dict | None:
    """The API's answer for one address, kept 5 minutes; None when the API cannot answer in time."""
    if not ip:
        return None
    hit = _cache.get(ip)
    if hit and time.monotonic() - hit[0] < 300:
        return hit[1]
    try:
        with urllib.request.urlopen(API + urllib.parse.quote(ip, safe=""), timeout=timeout) as res:
            answer = json.load(res)
    except Exception:
        return None
    if len(_cache) > 10_000:
        _cache.clear()
    _cache[ip] = (time.monotonic(), answer)
    return answer


def verdict(a: dict | None) -> tuple[str, str]:
    """("allow" | "challenge" | "block", reason), by FFraud's risk bands; the flags give the reason."""
    if a is None:
        return "allow", "IP check unavailable"
    if a.get("tor"):
        why = "Tor exit"
    elif a.get("vpn"):
        why = f"VPN ({a['vpn_provider']})" if a.get("vpn_provider") else "VPN"
    elif a.get("proxy"):
        why = "proxy"
    elif a.get("relay"):
        why = "privacy relay"
    elif a.get("hosting"):
        why = f"data center ({a['cloud_provider']})" if a.get("cloud_provider") else "data center"
    else:
        why = "no flag"
    risk = a.get("risk", "none")
    if risk == "critical" or (risk == "high" and a.get("recent_abuse")):
        return "block", why
    if risk in ("high", "medium"):
        return "challenge", why
    return "allow", why
  • One call, 2 seconds. ip_check asks /public/ip/{ip} with urlopen's timeout. Any error returns None, a 429 included, and verdict then lets the request through.
  • A cache in memory. A Python web process lives across requests, so a dict keeps each answer for 5 minutes. The API marks its answers cacheable for as long (Cache-Control: max-age=300, October 26, 2026). Each worker process keeps its own.
  • The band decides, the flags explain. Block a critical score or a high one with recent abuse, challenge medium and high, allow the rest (FFraud docs).

Many Python guides call an API with requests and no timeout. By default, requests does not time out, and code without one "may hang for minutes or more" (Requests docs).

#How do you add it to Flask?

flask_app.py
import functools

from flask import Flask, abort, g, request
from werkzeug.middleware.proxy_fix import ProxyFix

from ipcheck import ip_check, verdict

app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1)  # one proxy of yours in front, so remote_addr is the visitor


def ip_guard(view):
    """Refuse critical addresses before the view runs; g.ip_verdict tells the view about a challenge."""
    @functools.wraps(view)
    def wrapper(*args, **kwargs):
        g.ip_verdict = verdict(ip_check(request.remote_addr))
        if g.ip_verdict[0] == "block":
            abort(403)
        return view(*args, **kwargs)
    return wrapper


@app.post("/signup")
@ip_guard
def signup():
    # create the account here; ask for a CAPTCHA or a confirmed email when challenge is True
    return {"ok": True, "challenge": g.ip_verdict[0] == "challenge"}

ProxyFix makes request.remote_addr the visitor's address behind your proxy. Flask's docs warn that "incoming headers can be faked", so set x_for to the number of proxies you run, and leave it out if you run none.

#How do you add it to Django?

myapp/decorators.py
import functools

from django.http import HttpResponseForbidden

from .ipcheck import ip_check, verdict


def ip_guard(view):
    """Refuse critical addresses before the view runs; request.ip_verdict tells the view about a challenge."""
    @functools.wraps(view)
    def wrapper(request, *args, **kwargs):
        # REMOTE_ADDR is the visitor once your own proxy hands the address over; see the X-Forwarded-For guide.
        request.ip_verdict = verdict(ip_check(request.META.get("REMOTE_ADDR")))
        if request.ip_verdict[0] == "block":
            return HttpResponseForbidden("Requests from this network are not accepted.")
        return view(request, *args, **kwargs)
    return wrapper

Save ipcheck.py as myapp/ipcheck.py and put @ip_guard above a view. REMOTE_ADDR holds the client's address (Django docs), the one that connected to your server. Our X-Forwarded-For guide shows how to make it the visitor behind a proxy.

#How do you add it to FastAPI?

fastapi_app.py
from fastapi import Depends, FastAPI, HTTPException, Request

from ipcheck import ip_check, verdict

app = FastAPI()


def ip_guard(request: Request) -> tuple[str, str]:
    # A plain def: FastAPI runs it in a thread pool, so the lookup does not block the event loop.
    # Run uvicorn with --forwarded-allow-ips set to your proxy, so request.client.host is the visitor.
    v = verdict(ip_check(request.client.host if request.client else None))
    if v[0] == "block":
        raise HTTPException(status_code=403, detail="Requests from this network are not accepted.")
    return v


@app.post("/signup")
def signup(ip_verdict: tuple[str, str] = Depends(ip_guard)):
    # create the account here; ask for a CAPTCHA or a confirmed email when challenge is True
    return {"ok": True, "challenge": ip_verdict[0] == "challenge"}

FastAPI runs a dependency declared with a plain def in an external thread pool (FastAPI docs), so the blocking lookup leaves the event loop free. Uvicorn reads X-Forwarded-For only from the addresses in --forwarded-allow-ips, which defaults to 127.0.0.1 and ::1 (Uvicorn docs).

#What does each kind of address get?

We drew 20 addresses each from four public lists on October 26, 2026, and read the free API's bands, which decide the verdict:

ListRisk bands from the APIVerdict
Tor exitscritical 20block, 20 of 20
PIA VPN serversmedium 16, high 4challenge 16, block 4
iCloud Private Relaylow 15, none 5allow, 20 of 20
AWS EC2none 17, low 2, medium 1allow 19, challenge 1

The Private Relay addresses came back as data center addresses too, so a rule on hosting alone would refuse Safari users with iCloud+. Apple warns that IP-based fraud checks may need adjusting for Private Relay (Apple developer docs).

#What does the lookup cost a request?

We timed 12 lookups with an empty cache and 1,000 reads from the cache, in one Python 3.13 process from our test machine in Germany:

What one Python request pays for the IP checkMilliseconds per lookup, Python 3.13.7, October 26, 2026
  1. Empty cache, slowest of 12445 ms
  2. Empty cache, median of 12188 ms
  3. Empty cache, fastest of 12136 ms
  4. Cached answer, median of 1,0000 ms

Source: FFraud test of ipcheck.py on Python 3.13, October 26, 2026

Only the first visit from an address in 5 minutes waits for the API. Your own times depend on your distance to the API.

#How do you test it?

We ran the module and all three entry points on Python 3.13 on October 26, 2026, with one API call a second at most. Flask, Werkzeug, Django and FastAPI were not installed: stand-ins supplied the names the files import, and the views ran as plain functions:

test run, October 26, 2026, 01:48 UTC
Python 3.13.7 2026-10-26T01:48:07+00:00
45.66.35.28  Tor exit              -> ('block', 'Tor exit')  179 ms
104.28.80.101  iCloud Private Relay -> ('allow', 'privacy relay')  478 ms
62.169.130.76  PIA VPN server      -> ('challenge', 'VPN (Private Internet Access)')  139 ms
15.193.8.246  AWS EC2              -> ('allow', 'data center (Amazon Web Services)')  285 ms
8.8.8.8  Google DNS                -> ('allow', 'data center (Google Cloud)')  173 ms
8.8.8.8 again: from the cache in 12.7 microseconds
no address (None) -> ('allow', 'IP check unavailable')
flask, Tor exit: abort(403)
flask, PIA VPN server: view returned {'ok': True, 'challenge': True}
django, Tor exit: HttpResponseForbidden 403 'Requests from this network are not accepted.'
django, iCloud Private Relay: view ran, ip_verdict ('allow', 'privacy relay')
fastapi, Tor exit: HTTPException 403 'Requests from this network are not accepted.'
fastapi, PIA VPN server: dependency gave ('challenge', 'VPN (Private Internet Access)'), endpoint returned {'ok': True, 'challenge': True}
API down, 192.0.2.10 -> ('allow', 'IP check unavailable')
empty cache, 12 calls: median 188 ms, fastest 136 ms, slowest 445 ms
cached answer, 1,000 reads: median 0.4 microseconds

Run each entry point once inside your own app before you ship it.

#Questions people ask

How do I detect a VPN IP address in Python?

Ask an IP API about the address on your server and act on its risk band. ip_check and verdict above do it with the standard library and FFraud's free endpoint.

Is there a Python package for VPN detection?

There are several, such as detectvpn, whose latest release, 0.1.3, dates from March 2023 (PyPI). A short module around a maintained API, like the one above, needs no package.

How do I get the client IP in Flask behind a proxy?

Wrap the app in Werkzeug's ProxyFix with x_for set to the number of proxies you run. request.remote_addr is then the visitor's address (Flask docs).

Does a blocking lookup slow down FastAPI?

Not when the dependency is a plain def. FastAPI runs it in a thread pool, so the event loop keeps serving other requests.

Should I block VPN users?

The bands above block a VPN only when it scores critical, or high with recent abuse. In our sample that was four of 20 PIA servers; the other 16 got a challenge and can go on.

What happens when the API is down?

ip_check returns None after at most 2 seconds, and verdict lets the request through.

To look up one address by hand, use the free proxy and VPN check or the IP lookup. Every field of the answer is listed in the IP intelligence docs. What is my ISP shows how sites classify your own connection.

Sources

  1. IP intelligence, FFraud docs
  2. Tell Flask it is behind a proxy, Flask documentation
  3. Request and response objects: HttpRequest.META, Django documentation 6.1
  4. Concurrency and async / await, FastAPI documentation
  5. Settings: --proxy-headers and --forwarded-allow-ips, Uvicorn documentation
  6. Advanced usage: timeouts, Requests documentation
  7. Prepare your network or web server for iCloud Private Relay, Apple Developer
  8. Tor bulk exit list, The Tor Project, October 26, 2026
  9. AWS IP address ranges, Amazon VPC User Guide
  10. Server list, version 6, Private Internet Access, October 26, 2026