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

This tutorial adds VPN detection to an actix-web app with [`vpndetection`](https://docs.vpndetection.io/integrations/rust), 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](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 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](https://docs.rs/vpndetection/latest/vpndetection/middleware/), the framework-agnostic half of our [axum layer](https://docs.vpndetection.io/integrations/axum): 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](https://mullvad.net/en/servers), 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](https://docs.rs/actix-web/latest/actix_web/struct.App.html#method.app_data), 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](https://docs.rs/actix-web/latest/actix_web/dev/struct.ConnectionInfo.html#method.realip_remote_addr) 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:

| Address | Through nginx | Straight to the app |
|---|---|---|
| `realip_remote_addr()` | `1.1.1.1` | `1.1.1.1` |
| `peer_addr()` | `172.28.5.12` | `141.98.252.67` |
| `xff(1)` | `141.98.252.67` | `1.1.1.1` |
| `behind(nginx)`, below | `141.98.252.67` | `141.98.252.67` |
| `actix-remote-ip` | `141.98.252.67` | `141.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`](https://crates.io/crates/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 visitors | `behind(nginx)` | `actix-remote-ip` |
|---|---|---|
| Mullvad servers flagged `is_vpn` | 10 of 10 | 4 of 10 |
| Residential proxy exits flagged `is_resproxy` | 10 of 10 | 0 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](https://www.cloudflare.com/ips/) 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:

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

