Skip to content
VPNDetection

How to detect VPN users in Rust with actix-web

Add VPN detection to an actix-web app with the vpndetection crate, and keep realip_remote_addr() from looking up an address the visitor forged.

Published 6 min read

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

This tutorial adds VPN detection to an actix-web app with vpndetection, our Rust client library. We publish no actix-web adapter, so a from_fn middleware hands each request to the crate's own middleware core. By the end, signup asks visitors on a VPN, a residential proxy or Tor to confirm their email, checkout turns them away, and only the routes under /auth pay for a lookup. Which address the middleware hands over decides whether any of it works: actix-web's own realip_remote_addr() returns the address a visitor writes in X-Forwarded-For, even behind a proxy. We ran every snippet against the live API on October 22, 2026, with Rust 1.99.1, actix-web 4.17.0, vpndetection 5.9.0 and actix-remote-ip 1.0.2, and every output below is from those runs.

Look up the routes that need it

bash
cargo new shop
cd shop
cargo add actix-web vpndetection serde_json

actix-web 4.17 needs Rust 1.88 or newer, and the crate 1.85. 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 actix_web::{
    App, Error, HttpMessage, HttpResponse, HttpServer,
    body::MessageBody,
    dev::{ServiceRequest, ServiceResponse},
    error::ErrorInternalServerError,
    middleware::{Next, from_fn},
    web,
};
use serde_json::json;
use vpndetection::middleware::{Core, Lookup, Options, RequestView};

#[actix_web::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);
    }
    // Built once, outside the closure, which actix calls once per worker.
    let core = web::Data::new(Core::new(options)?);

    HttpServer::new(move || {
        // Only the routes under /auth pay for a lookup.
        let auth = web::scope("/auth")
            .wrap(from_fn(vpn_check))
            .route("/check", web::get().to(check))
            .route("/signup", web::post().to(signup))
            .route("/checkout", web::post().to(checkout));
        App::new().app_data(core.clone()).service(auth)
    })
    .bind(("0.0.0.0", 8093))?
    .run()
    .await?;
    Ok(())
}

// Looks the visitor up and leaves the answer on the request.
async fn vpn_check(
    core: web::Data<Core>,
    req: ServiceRequest,
    next: Next<impl MessageBody>,
) -> Result<ServiceResponse<impl MessageBody>, Error> {
    // Copied out of the request: RequestView needs Send + Sync, which it isn't.
    let headers = req.headers().clone();
    let peer = req.peer_addr().map(|addr| addr.ip().to_string());
    let header = |name: &str| headers.get(name)?.to_str().ok().map(str::to_owned);
    let framework_ip = || peer.clone();
    let view = RequestView {
        header: &header,
        framework_ip: &framework_ip,
    };
    let found = core
        .evaluate(&view)
        .await
        .map_err(ErrorInternalServerError)?;
    req.extensions_mut().insert(found);
    next.call(req).await
}

async fn check(found: web::ReqData<Lookup>) -> HttpResponse {
    HttpResponse::Ok().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 middleware looks up each request under /auth before its handler runs and leaves the answer on the request, where web::ReqData<Lookup> reads it. Nothing is blocked. Core comes from the crate's middleware module, the framework-agnostic half of our axum layer: it picks the address, looks it up with a 2.5-second timeout and no retries, and lets the request through when that fails. RequestView is how a framework hands it a request, and its closures must be Send + Sync. Capture req in them and the build fails, since Rc<actix_web::request::HttpRequestInner> cannot be shared between threads safely, so the middleware copies the headers and the address out first.

From 141.98.252.67, a Mullvad server in London from Mullvad's own server list, part of result read:

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

That's a key on the Max plan. The answer also has location and asn: the United Kingdom and AS39351 here. A home broadband address on a US cable ISP came back with every flag false, every detail object empty, and location and asn filled in. A private or reserved address never reaches the API: the crate answers it in the process.

The Core is built once, in main, because actix-web calls the closure once per worker thread, one worker per CPU core by default. Each Core holds its own client and cache, so one built inside the closure is one per worker: on a container with 4 cores, the same visitor sent 8 times cost 4 lookups, counted at a proxy that logged each one, against 1 with the code above.

Everything in the crate is async, and each worker runs its own runtime, so await the lookup as the middleware does. The README's recipe for code with no runtime, a tokio Runtime and its block_on, panicked in a handler with Cannot start a runtime from within a runtime, and the visitor got an empty reply.

Hand it the visitor's address

The middleware hands Core the connection's own address, peer_addr(). actix-web also offers a "real IP", ConnectionInfo::realip_remote_addr():

rust
    let peer = req
        .connection_info()
        .realip_remote_addr()
        .map(str::to_owned);

It reads a Forwarded header first, then the left-most X-Forwarded-For entry, and the connection only when neither is there. Its documentation warns about exactly that:

Do not use this function for security purposes unless you can be sure that the Forwarded and X-Forwarded-For headers cannot be spoofed by the client.

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 each choice looked up:

AddressThrough nginxStraight to the app
realip_remote_addr()1.1.1.11.1.1.1
peer_addr()172.28.5.12141.98.252.67
xff(1)141.98.252.671.1.1.1
behind(nginx), below141.98.252.67141.98.252.67
actix-remote-ip141.98.252.67141.98.252.67

nginx appends the address it saw, so the forgery stays first, and realip_remote_addr() returns it either way. 1.1.1.1 answers is_cdn and is_hosting but not is_vpn, so a VPN check waves the visitor through. Overwriting the header in nginx, with $remote_addr, didn't save it: nginx passes a Forwarded header on untouched, and a visitor who sent Forwarded: for=1.1.1.1 was looked up as 1.1.1.1.

peer_addr() is right with nothing in front of the app. Behind nginx, every visitor is nginx, a private address the crate answers locally, and nobody is ever flagged. The one sign is a line in the app's output:

[vpndetection] 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.

The crate's xff(1) reads one entry from the right, the one nginx appended: the visitor behind nginx, and the forgery straight to the app. actix-web has no trusted-proxy setting, so check the connection before you read the header:

rust
use std::net::IpAddr;
use std::sync::Arc;

use vpndetection::middleware::{IpSelector, xff};

// The right-most X-Forwarded-For entry on a connection from `proxy`,
// and the connection's own address on any other.
fn behind(proxy: IpAddr) -> IpSelector {
    let right_most = xff(1);
    Arc::new(move |view| {
        let peer = (view.framework_ip)()?;
        match peer.parse::<IpAddr>() {
            Ok(ip) if ip.to_canonical() == proxy => right_most(view),
            _ => Some(peer),
        }
    })
}

In main:

rust
    let mut options = Options::new();
    if let Ok(key) = std::env::var("VPNDETECTION_API_KEY") {
        options = options.api_key(key);
    }
    options = options.ip_selector(behind("172.28.5.12".parse()?));

to_canonical() is for an app bound to ::, where nginx connects as ::ffff:172.28.5.12. Without it, that app never recognized nginx, and every visitor was nginx again.

actix-remote-ip does the same walk from a list of trusted proxies. With nginx in that list, through RemoteIPConfig::parse("172.28.5.12") in app_data and its RemoteIP extractor among the middleware's arguments, both columns came out right. It also zeroes the last 64 bits of an IPv6 address, which its README mentions and its code puts down to brute-force protection. We sent 10 Mullvad servers' IPv6 addresses and 10 IPv6 residential proxy exits from our data, each in X-Forwarded-For from nginx's address:

IPv6 visitorsbehind(nginx)actix-remote-ip
Mullvad servers flagged is_vpn10 of 104 of 10
Residential proxy exits flagged is_resproxy10 of 100 of 10

Mullvad's us-atl-wg-405, at 2607:9000:c00:35::f001, was looked up as 2607:9000:c00:35::, which answers is_vpn: false.

Behind a CDN, the connecting address is the CDN's: from a Cloudflare address, peer_addr() looked up that address and found is_cdn: true. With header added to the vpndetection::middleware import, read the header Cloudflare writes instead:

rust
    options = options.ip_selector(header("CF-Connecting-IP"));

Sent from Cloudflare's address with CF-Connecting-IP: 141.98.252.67, the lookup found the VPN, and without the header, the selector fell back to the connecting address. Straight to the app, a visitor's own 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 middleware has already looked the visitor up and each handler only has to decide. Further down the same main.rs:

rust
// A VPN, residential proxy or Tor visitor, or one we know nothing about.
fn masked(found: &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: web::ReqData<Lookup>) -> HttpResponse {
    HttpResponse::Ok().json(json!({ "confirm_email": masked(&found) }))
}

// Checkout: a VPN, Tor, or a residential proxy exit seen 5 times or more.
async fn checkout(found: web::ReqData<Lookup>) -> HttpResponse {
    if let Some(r) = &found.result {
        let hits = r.resproxy.as_ref().and_then(|d| d.hits).unwrap_or(0);
        let busy_proxy = r.is_resproxy_or_false() && hits >= 5;
        if r.is_vpn || r.is_tor_or_false() || busy_proxy {
            let error = "Please turn off your VPN or proxy to check out.";
            return HttpResponse::Forbidden().json(json!({ "error": error }));
        }
    }
    HttpResponse::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.

With behind(nginx) in place, we sent both routes the Mullvad server through nginx, then a home address and two residential proxy exits from our data as nginx hands them over, one exit with hits at 25 and one at 1:

VisitorPOST /auth/signupPOST /auth/checkout
Mullvad server{"confirm_email":true}403
Home broadband{"confirm_email":false}200
Residential proxy exit, hits 25{"confirm_email":true}403
Residential proxy exit, hits 1{"confirm_email":true}200

Each 403 came with {"error":"Please turn off your VPN or proxy to check out."}.

When a lookup fails

Core 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 the app's api.vpndetection.io pointed at an address that refuses connections, /auth/check answered in about 4 ms, its error starting vpndetection: network: error sending request. Pointed at one that never answers, every request under /auth took 2.5 seconds, the timeout Options sets, its error starting vpndetection: network: timed out. With an invalid key, error read:

vpndetection: unauthorized (HTTP 401): invalid API key

Each time, 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.

Options::timeout changes those 2.5 seconds. A Client you hand over with Options::client keeps its own settings instead, and one built with the defaults held a signup for 31 seconds against the API that never answered.

Each Core keeps answers for an hour, up to 10,000 addresses, so a returning visitor costs nothing within the hour: the 8 requests above cost one lookup. 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 server's result from the first section reads:

json
{
  "ip": "141.98.252.67",
  "is_vpn": true
}

Every other field is missing, which means "not in your plan", not "checked, and no", and in Rust that's None against Some(false). is_vpn comes back without a key, a free key adds the Tor and relay flags, location and asn, and every paid plan answers every field.

So without a key, checkout still refused the Mullvad server on is_vpn, while the proxy exit with 25 hits signed up without confirming an email and placed its order.

The same setup with our axum layer is in How to detect VPN users in Rust with axum.

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. 1,000 lookups a day per address, answering ip and is_vpn.

curl "https://api.vpndetection.io/146.70.116.130"
{
"ip": "146.70.116.130",
"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.