Skip to content
VPNDetection

How to detect VPN users in Rust with axum

Add VPN detection to an axum app with vpndetection-axum: serve it with ConnectInfo, count your proxies with xff(1), and decide per route under /auth.

Published 4 min read

On this page
  1. Look up the routes that need it
  2. Give it an address
  3. Decide per route
  4. When a lookup fails
  5. Absent is not false

This tutorial adds VPN detection to an axum app with vpndetection-axum, our official layer. By the end, signup asks visitors on a VPN, a residential proxy or Tor to confirm their email, and checkout turns them away, and only the routes under /auth pay for a lookup. Before either works, axum has to hand the layer an address at all: it reads ConnectInfo, which axum doesn't provide unless you serve the app with into_make_service_with_connect_info. We ran every snippet against the live API on October 9, 2026, with Rust 1.99.0, axum 0.8.9, tokio 1 and vpndetection-axum 3.0.11, and every output below is from those runs.

Look up the routes that need it

bash
cargo add vpndetection-axum vpndetection axum serde_json
cargo add tokio --features full

The crate needs Rust 1.85 or newer and axum 0.8. 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.

rust
// src/main.rs
use axum::{
    Json, Router,
    http::StatusCode,
    routing::{get, post},
};
use serde_json::{Value, json};
use vpndetection::middleware::Options;
use vpndetection_axum::{Found, VPNDetection};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut options = Options::new();
    if let Ok(key) = std::env::var("VPNDETECTION_API_KEY") {
        options = options.api_key(key);
    }
    let guard = VPNDetection::new(options)?;

    // Only the routes under /auth pay for a lookup.
    let auth = Router::new()
        .route("/check", get(check))
        .route("/signup", post(signup))
        .route("/checkout", post(checkout))
        .layer(guard);
    let app = Router::new().nest("/auth", auth);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8092").await?;
    axum::serve(
        listener,
        app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
    )
    .await?;
    Ok(())
}

async fn check(Found(found): Found) -> Json<Value> {
    Json(json!({
        "looked_up": found.ip,
        "result": found.result.as_ref().map(|r| &r.answer),
        "error": found.error.as_ref().map(|e| e.to_string()),
    }))
}

The layer looks up each request under /auth before its handler runs, and the Found extractor hands the handler the answer. Nothing is blocked. answer is the API's response as it came. From 141.98.252.67, a Mullvad server in London from Mullvad's own server list, part of result read:

json
{
  "is_vpn": true,
  "is_hosting": true,
  "is_tor": false,
  "is_resproxy": false,
  "vpn": {
    "confidence": "high",
    "last_seen": "2026-10-09",
    "method": "scan",
    "provider": "mullvad"
  }
}

That's a key on the Max plan. A home broadband address on a US cable ISP came back with every flag false and every detail object empty. A private or reserved address never reaches the API: the layer answers it in the process.

Give it an address

The layer reads axum's ConnectInfo<SocketAddr>, the address that opened the connection, and axum attaches it only when the app is served through into_make_service_with_connect_info, as above. Served with a plain axum::serve(listener, app), every request came back with no answer and this error:

vpndetection: no client address on the request

The log said the same, once:

[vpndetection] could not resolve a client address from this request; pass an ip_selector that knows where yours comes from

The layer fails open, so the app keeps working and nobody is ever flagged.

With ConnectInfo in place, the address is the connection's, so behind a proxy it's the proxy. We put nginx 1.29.1 in front of the app in a container of its own, at 172.28.5.12 on a private network, with the usual proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for. 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 layer looked up:

SelectorThrough nginxStraight to the app
ConnectInfo, the default172.28.5.12141.98.252.67
xff(1)141.98.252.671.1.1.1

xff(1) reads one entry from the right of X-Forwarded-For, the one nginx appended:

rust
use vpndetection::middleware::{Options, xff};

    let mut options = Options::new();
    if let Ok(key) = std::env::var("VPNDETECTION_API_KEY") {
        options = options.api_key(key);
    }
    options = options.ip_selector(xff(1));

Behind nginx, that's the visitor. Straight to the app, the only entry is the one the visitor wrote, and 1.1.1.1 answers is_hosting but not is_vpn, so a VPN check waves them through. So bind the app where only nginx can reach it, such as 127.0.0.1 or a private network the two share, and count one entry per proxy a request passes.

Behind a CDN, the connecting address is the CDN. From a Cloudflare address, the default looked up that address and found is_cdn: true. Read the header the CDN writes instead:

rust
use vpndetection::middleware::{Options, header};

    let mut options = Options::new();
    if let Ok(key) = std::env::var("VPNDETECTION_API_KEY") {
        options = options.api_key(key);
    }
    options = options.ip_selector(header("CF-Connecting-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

Signup and checkout sit under /auth, so the layer has already looked the visitor up and each handler only has to decide:

rust
// A VPN, residential proxy or Tor visitor, or one we know nothing about.
fn masked(found: &vpndetection::middleware::Lookup) -> bool {
    match &found.result {
        None => true,
        Some(r) => r.is_vpn || r.is_resproxy_or_false() || r.is_tor_or_false(),
    }
}

// Signup: nobody is refused, but a masked visitor confirms their email first.
async fn signup(Found(found): Found) -> Json<Value> {
    Json(json!({ "confirm_email": masked(&found) }))
}

// Checkout: a VPN, Tor, or a residential proxy exit seen 5 times or more.
async fn checkout(Found(found): Found) -> (StatusCode, Json<Value>) {
    if let Some(r) = &found.result {
        let hits = r.resproxy.as_ref().and_then(|d| d.hits).unwrap_or(0);
        let refuse = r.is_vpn
            || r.is_tor_or_false()
            || (r.is_resproxy_or_false() && hits >= 5);
        if refuse {
            let error = "Please turn off your VPN or proxy to check out.";
            return (StatusCode::FORBIDDEN, Json(json!({ "error": error })));
        }
    }
    (StatusCode::OK, Json(json!({ "ordered": true })))
}

Every flag but is_vpn is an Option<bool>, and None means your plan doesn't include it, so is_tor_or_false() and the other _or_false helpers read a missing flag as false. masked also answers true when the lookup failed, since then you know nothing about the visitor.

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:

VisitorPOST /auth/signupPOST /auth/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."}. To refuse a condition on every route the layer covers instead, give Options a block_condition, written in the shape of the answer, such as Condition::from([("is_vpn", true)]).

When a lookup fails

The layer 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, /auth/check answered:

vpndetection: unauthorized (HTTP 401): invalid API key

With the same key, signup asked the Mullvad visitor for their email, and checkout took their order. Fail open, and ask more of a visitor whose lookup failed, as signup does.

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
{
  "ip": "141.98.252.67",
  "is_vpn": true
}

Everything else is missing, which means "not in your plan", not "checked, and no". In Rust that's None against Some(false). 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.

The same setup in Django is in How to detect VPN users in Django.

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.