On this page
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
cargo add vpndetection-axum vpndetection axum serde_json
cargo add tokio --features fullThe 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.
// 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:
{
"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:
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:
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:
// 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). 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:
{
"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.


