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

This tutorial adds VPN detection to an axum app with [`vpndetection-axum`](https://docs.vpndetection.io/integrations/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](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.

```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](https://mullvad.net/en/servers), 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:

| Selector | Through nginx | Straight to the app |
|---|---|---|
| `ConnectInfo`, the default | `172.28.5.12` | `141.98.252.67` |
| `xff(1)` | `141.98.252.67` | `1.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](https://www.cloudflare.com/ips/) 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:

| Visitor | `POST /auth/signup` | `POST /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](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
{
  "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](https://vpndetection.io/blog/detect-vpn-users-django).

